CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
251 lines
8.2 KiB
Rust
251 lines
8.2 KiB
Rust
//! natives jar 的读取细节:zip 遍历、条目筛选、目录清理、戳记读写。
|
||
//!
|
||
//! 与 [`super`] 的分工:`super` 管「计划与报告」,这里管「怎么把一个 jar 里的原生库落到盘上」。
|
||
|
||
use std::fs::{self, File};
|
||
use std::io;
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::UNIX_EPOCH;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use zip::ZipArchive;
|
||
|
||
use super::{NativeJar, NativesError, NativesReport, STAMP_FILE};
|
||
|
||
/// 戳记 schema 版本(改字段语义才升)。
|
||
const STAMP_VERSION: u8 = 1;
|
||
|
||
/// 内置排除前缀:Mojang 清单惯例,manifest 没写 `extract` 规则时也这么办。
|
||
const DEFAULT_EXCLUDES: &[&str] = &["META-INF/"];
|
||
|
||
/// 原生库扩展名白名单(Windows / Linux / macOS;`jnilib` 是 JDK6 时代的老 mac 后缀)。
|
||
const NATIVE_EXTENSIONS: &[&str] = &["dll", "so", "dylib", "jnilib"];
|
||
|
||
/// 签名 / 校验附属文件扩展名:即使在 natives jar 根目录也绝不落地。
|
||
const SIGNATURE_EXTENSIONS: &[&str] = &["sf", "rsa", "dsa", "ec", "asc", "sha1", "sha256", "git"];
|
||
|
||
/// 戳记的一条:源 jar 的「路径 + 字节数 + mtime」指纹。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub(crate) struct JarStamp {
|
||
/// 源 jar 绝对路径(同一目录换了别的版本时必须能区分开)。
|
||
pub path: String,
|
||
/// 字节数。
|
||
pub len: u64,
|
||
/// 修改时间(Unix 毫秒;读不到时 0,等价于「每次都当它变了」)。
|
||
pub mtime_ms: u64,
|
||
}
|
||
|
||
/// 目录戳记文件内容。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub(crate) struct Stamp {
|
||
v: u8,
|
||
files: usize,
|
||
jars: Vec<JarStamp>,
|
||
}
|
||
|
||
impl Stamp {
|
||
/// 戳记是否仍然对应当前的 jar 集(版本 + 指纹逐一相等)。
|
||
pub(crate) fn matches(&self, stamps: &[JarStamp]) -> bool {
|
||
self.v == STAMP_VERSION && self.jars == stamps
|
||
}
|
||
|
||
/// 上次解压的文件数。
|
||
pub(crate) fn files(&self) -> usize {
|
||
self.files
|
||
}
|
||
}
|
||
|
||
/// 构造 `NativesError::Io`。
|
||
pub(crate) fn io_error(
|
||
op: &'static str,
|
||
path: impl Into<PathBuf>,
|
||
source: io::Error,
|
||
) -> NativesError {
|
||
NativesError::Io {
|
||
op,
|
||
path: path.into(),
|
||
source,
|
||
}
|
||
}
|
||
|
||
/// 解压单个 jar:排除前缀 → 原生库白名单 → zip-slip 防护 → 落盘。
|
||
pub(crate) fn extract_jar(
|
||
native: &NativeJar,
|
||
dir: &Path,
|
||
report: &mut NativesReport,
|
||
) -> 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 {
|
||
path: native.path.clone(),
|
||
reason: err.to_string(),
|
||
})?;
|
||
let excludes: Vec<&str> = DEFAULT_EXCLUDES
|
||
.iter()
|
||
.copied()
|
||
.chain(native.exclude.iter().map(String::as_str))
|
||
.collect();
|
||
|
||
for index in 0..archive.len() {
|
||
let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip {
|
||
path: native.path.clone(),
|
||
reason: err.to_string(),
|
||
})?;
|
||
if entry.is_dir() {
|
||
continue;
|
||
}
|
||
// `enclosed_name` 拒绝 `..` / 绝对路径条目(zip-slip)。
|
||
let Some(relative) = entry.enclosed_name() else {
|
||
report.ignored += 1;
|
||
continue;
|
||
};
|
||
let name = relative.to_string_lossy().replace('\\', "/");
|
||
if !should_extract(&name, &excludes) {
|
||
report.ignored += 1;
|
||
continue;
|
||
}
|
||
let dest = dir.join(&relative);
|
||
if let Some(parent) = dest.parent() {
|
||
fs::create_dir_all(parent)
|
||
.map_err(|source| io_error("create_natives_subdir", parent, source))?;
|
||
}
|
||
let mut out =
|
||
File::create(&dest).map_err(|source| io_error("create_native_file", &dest, source))?;
|
||
io::copy(&mut entry, &mut out)
|
||
.map_err(|source| io_error("write_native_file", &dest, source))?;
|
||
report.files += 1;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。
|
||
pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool {
|
||
if excludes.iter().any(|prefix| name.starts_with(prefix)) {
|
||
return false;
|
||
}
|
||
let lower = name.to_ascii_lowercase();
|
||
let extension = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("");
|
||
if SIGNATURE_EXTENSIONS.contains(&extension) {
|
||
return false;
|
||
}
|
||
NATIVE_EXTENSIONS.contains(&extension)
|
||
}
|
||
|
||
/// 清掉目录里已有的原生库与戳记(尽力而为:删不掉的留着,后面覆盖写会报错)。
|
||
pub(crate) fn clear_natives(dir: &Path) {
|
||
let Ok(entries) = fs::read_dir(dir) else {
|
||
return;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
continue;
|
||
}
|
||
let name = path
|
||
.file_name()
|
||
.map(|name| name.to_string_lossy().to_ascii_lowercase())
|
||
.unwrap_or_default();
|
||
if name == STAMP_FILE {
|
||
let _ = fs::remove_file(&path);
|
||
continue;
|
||
}
|
||
let is_native = name
|
||
.rsplit_once('.')
|
||
.map(|(_, ext)| NATIVE_EXTENSIONS.contains(&ext))
|
||
.unwrap_or(false);
|
||
if is_native {
|
||
let _ = fs::remove_file(&path);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 源 jar 指纹(路径 + 字节数 + mtime)。
|
||
pub(crate) fn jar_stamps(jars: &[NativeJar]) -> Vec<JarStamp> {
|
||
jars.iter()
|
||
.map(|native| {
|
||
let metadata = fs::metadata(&native.path);
|
||
let len = metadata.as_ref().map(|meta| meta.len()).unwrap_or(0);
|
||
let mtime_ms = metadata
|
||
.ok()
|
||
.and_then(|meta| meta.modified().ok())
|
||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||
.map(|elapsed| elapsed.as_millis() as u64)
|
||
.unwrap_or(0);
|
||
JarStamp {
|
||
path: native.path.display().to_string(),
|
||
len,
|
||
mtime_ms,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有戳记」)。
|
||
pub(crate) fn read_stamp(dir: &Path) -> Option<Stamp> {
|
||
let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
|
||
let stamp: Stamp = serde_json::from_str(&text).ok()?;
|
||
if stamp.v == STAMP_VERSION {
|
||
Some(stamp)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// 写戳记(直接覆盖写:半截文件下次解析失败即视为「没有戳记」,重解一遍就行)。
|
||
pub(crate) fn write_stamp(dir: &Path, files: usize, jars: &[JarStamp]) -> Result<(), NativesError> {
|
||
let path = dir.join(STAMP_FILE);
|
||
let stamp = Stamp {
|
||
v: STAMP_VERSION,
|
||
files,
|
||
jars: jars.to_vec(),
|
||
};
|
||
let text = serde_json::to_string_pretty(&stamp).map_err(|err| NativesError::Zip {
|
||
path: path.clone(),
|
||
reason: err.to_string(),
|
||
})?;
|
||
fs::write(&path, text).map_err(|source| io_error("write_natives_stamp", &path, source))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{should_extract, DEFAULT_EXCLUDES};
|
||
|
||
#[test]
|
||
fn native_library_extensions_pass() {
|
||
for name in [
|
||
"lwjgl.dll",
|
||
"OpenAL.dll",
|
||
"liblwjgl.so",
|
||
"liblwjgl.dylib",
|
||
"liblwjgl.jnilib",
|
||
] {
|
||
assert!(should_extract(name, DEFAULT_EXCLUDES), "{name} 应解压");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn metadata_and_signature_entries_are_skipped() {
|
||
for name in [
|
||
"META-INF/MANIFEST.MF",
|
||
"META-INF/FOO.SF",
|
||
"META-INF/FOO.RSA",
|
||
"lwjgl.dll.sha1",
|
||
"lwjgl.dll.sha256",
|
||
"README.txt",
|
||
"LICENSE",
|
||
// 无扩展名的文件一律不进 java.library.path。
|
||
"lwjgl",
|
||
] {
|
||
assert!(!should_extract(name, DEFAULT_EXCLUDES), "{name} 不该解压");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_exclude_prefixes_apply_on_top_of_builtin() {
|
||
assert!(!should_extract("extra/x.dll", &["extra/"]));
|
||
assert!(should_extract("x.dll", &["extra/"]));
|
||
// 大小写不敏感只对扩展名生效(排除前缀按清单原样比较,与 HMCL 一致)。
|
||
assert!(should_extract("X.DLL", DEFAULT_EXCLUDES));
|
||
}
|
||
}
|