Files
Unlucky-Launcher/crates/core/src/game/java/extract.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

319 lines
11 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.
//! 归档解压:zip(Windows)/ tar.gz(Linux、macOS 的 Temurin 只发这种)+ 幂等戳记。
//!
//! # 为什么不用「解压到最终目录」了事
//!
//! Temurin 的归档里所有条目都在一个顶层目录下(`jdk-21.0.13+9/bin/java`),
//! 而托管布局要的是 `java-managed/jdk-21-temurin/bin/java`。裸解压会多出一层,
//! 于是这里先在**暂存目录**里解完,再把那个唯一的顶层目录(或暂存目录本身)
//! 提升成最终目录——这样终态恒定,`bin/java` 的位置不会因为厂商换打包方式而漂移。
//!
//! # 幂等
//!
//! 戳记 `.ull-jdk.json` 记下「这份目录是用哪个 sha256 的归档解出来的 + 文件数」。
//! 重复 `ull java install 21` 时 sha256 相同就**一个字节都不动**(返回 `skipped`);
//! 官方发了新版(sha256 变)才重解。戳记缺失/损坏一律当「没装过」。
//!
//! # 安全
//!
//! zip 走 `enclosed_name()`(拒绝 `..` 与绝对路径),tar 走 `unpack_in()`
//! (同样拒绝越界),两者都不会把文件写到目标目录之外(zip-slip)。
//! tar 里的符号链接条目跳过(Windows 建不了;Temurin 的 JDK 归档不依赖它们)。
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use zip::ZipArchive;
use super::error::{io_error, JavaError, JavaErrorKind};
/// 戳记文件名(放在托管 JDK 根目录里)。
pub const STAMP_FILE: &str = ".ull-jdk.json";
/// 戳记 schema 版本(改字段语义才升)。
const STAMP_VERSION: u8 = 1;
/// 暂存目录前缀(解压在它里面进行,成功后提升成最终目录)。
const STAGING_PREFIX: &str = ".staging-";
/// 托管 JDK 的戳记。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JdkStamp {
/// schema 版本。
pub v: u8,
/// 归档文件名。
pub archive: String,
/// 归档的 SHA-256(幂等判据)。
pub sha256: String,
/// 大版本号。
pub major: u32,
/// 解出来的文件数。
pub files: usize,
}
/// 一次解压的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExtractReport {
/// 本次落盘的文件数(`skipped` 时为 0)。
pub files: usize,
/// 是否因为戳记命中而整体跳过。
pub skipped: bool,
}
impl ExtractReport {
/// 是否真的写盘了。
#[must_use]
pub const fn wrote(self) -> bool {
!self.skipped
}
}
/// 托管 JDK 的 `java` 可执行文件路径。
#[must_use]
pub fn java_binary(dir: &Path) -> PathBuf {
dir.join("bin").join(java_binary_name())
}
/// 平台上的 java 可执行文件名。
#[must_use]
pub const fn java_binary_name() -> &'static str {
if cfg!(windows) {
"java.exe"
} else {
"java"
}
}
/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有」)。
#[must_use]
pub fn read_stamp(dir: &Path) -> Option<JdkStamp> {
let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
let stamp: JdkStamp = serde_json::from_str(&text).ok()?;
(stamp.v == STAMP_VERSION).then_some(stamp)
}
/// 这个目录里是不是已经躺着一份用**同一个归档**解出来的 JDK。
#[must_use]
pub fn is_ready(dir: &Path, sha256: &str) -> bool {
let Some(stamp) = read_stamp(dir) else {
return false;
};
stamp.sha256 == sha256.to_ascii_lowercase() && java_binary(dir).is_file()
}
/// 解压一个 JDK 归档到 `dest`(幂等)。
///
/// # Errors
///
/// 归档打不开/格式不认识、解压写盘失败、`dest` 已存在却不由我们管理
/// (没有戳记)时返回错误。
pub fn extract_archive(
archive: &Path,
sha256: &str,
major: u32,
dest: &Path,
) -> Result<ExtractReport, JavaError> {
if is_ready(dest, sha256) {
return Ok(ExtractReport {
files: 0,
skipped: true,
});
}
let parent = dest.parent().ok_or_else(|| {
JavaError::new(
JavaErrorKind::Io,
format!("托管目录没有父目录:{}", dest.display()),
)
})?;
fs::create_dir_all(parent)
.map_err(|error| io_error("创建托管目录", parent.display(), &error))?;
let folder = dest
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "jdk".to_string());
let staging = parent.join(format!("{STAGING_PREFIX}{folder}"));
if staging.exists() {
fs::remove_dir_all(&staging)
.map_err(|error| io_error("清理暂存目录", staging.display(), &error))?;
}
fs::create_dir_all(&staging)
.map_err(|error| io_error("创建暂存目录", staging.display(), &error))?;
let extracted = if is_zip(archive) {
extract_zip(archive, &staging)
} else {
extract_tar_gz(archive, &staging)
};
let files = match extracted {
Ok(files) => files,
Err(error) => {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
};
let root = single_top_level_dir(&staging).unwrap_or_else(|| staging.clone());
if dest.exists() {
// 只回收「我们自己装的」目录:有戳记,或空目录。其余一律不碰。
let ours = read_stamp(dest).is_some() || is_empty_dir(dest);
if !ours {
let _ = fs::remove_dir_all(&staging);
return Err(JavaError::new(
JavaErrorKind::Io,
format!("目标目录已存在且不是托管 JDK:{}", dest.display()),
)
.with_hint("换个目录(`--data-dir`),或先手动清掉它"));
}
fs::remove_dir_all(dest)
.map_err(|error| io_error("清空旧托管目录", dest.display(), &error))?;
}
fs::rename(&root, dest).map_err(|error| {
io_error(
"提升暂存目录",
format!("{} → {}", root.display(), dest.display()),
&error,
)
})?;
if root != staging {
let _ = fs::remove_dir_all(&staging);
}
if !java_binary(dest).is_file() {
return Err(JavaError::new(
JavaErrorKind::Probe,
format!("解压完成但找不到 bin/{}", java_binary_name()),
)
.with_hint("归档形态与平台不匹配(例如把 Linux 包当成 Windows 包装)"));
}
let stamp = JdkStamp {
v: STAMP_VERSION,
archive: archive
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default(),
sha256: sha256.to_ascii_lowercase(),
major,
files,
};
let text = serde_json::to_string_pretty(&stamp).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("序列化 JDK 戳记失败:{error}"),
)
})?;
let stamp_path = dest.join(STAMP_FILE);
fs::write(&stamp_path, text)
.map_err(|error| io_error("写 JDK 戳记", stamp_path.display(), &error))?;
Ok(ExtractReport {
files,
skipped: false,
})
}
/// 归档是不是 zip(魔数优先,读不到才退回扩展名)。
///
/// 不按扩展名一刀切:镜像把 `.zip` 存成 `.zip.tmp` 之类时魔数仍然可靠,
/// 反过来把 tar.gz 命名成 `.zip` 也不会被错解。
#[must_use]
pub fn is_zip(archive: &Path) -> bool {
let mut head = [0u8; 4];
let magic = File::open(archive)
.and_then(|mut file| io::Read::read_exact(&mut file, &mut head).map(|()| head));
if let Ok(head) = magic {
// `PK\x03\x04` 普通条目 / `PK\x05\x06` 空归档。
return head == *b"PK\x03\x04" || head == *b"PK\x05\x06";
}
archive
.file_name()
.map(|name| name.to_string_lossy().to_ascii_lowercase())
.is_some_and(|name| name.ends_with(".zip"))
}
/// zip:逐条目落盘,`enclosed_name` 挡路径穿越。
fn extract_zip(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
let file = File::open(archive)
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
let mut zip = ZipArchive::new(file).map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("JDK 归档不是合法 zip({}):{error}", archive.display()),
)
})?;
let mut files = 0;
for index in 0..zip.len() {
let mut entry = zip.by_index(index).map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("读 zip 条目失败({}):{error}", archive.display()),
)
})?;
if entry.is_dir() {
continue;
}
let Some(relative) = entry.enclosed_name() else {
continue;
};
let dest = dir.join(&relative);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|error| io_error("创建解压子目录", parent.display(), &error))?;
}
let mut out = File::create(&dest)
.map_err(|error| io_error("创建解压文件", dest.display(), &error))?;
io::copy(&mut entry, &mut out)
.map_err(|error| io_error("写解压文件", dest.display(), &error))?;
files += 1;
}
Ok(files)
}
/// tar.gz:`unpack_in` 挡路径穿越,符号链接条目跳过。
fn extract_tar_gz(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
let file = File::open(archive)
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
let mut tar = tar::Archive::new(GzDecoder::new(file));
let entries = tar.entries().map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("JDK 归档不是合法 tar.gz({}):{error}", archive.display()),
)
})?;
let mut files = 0;
for entry in entries {
let mut entry = entry.map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("读 tar 条目失败({}):{error}", archive.display()),
)
})?;
if !entry.header().entry_type().is_file() {
continue;
}
let unpacked = entry
.unpack_in(dir)
.map_err(|error| io_error("解压 tar 条目", dir.display(), &error))?;
if unpacked {
files += 1;
}
}
Ok(files)
}
/// 暂存目录里是不是只有一个顶层目录(Temurin 的 `jdk-21.0.13+9/`)。
fn single_top_level_dir(dir: &Path) -> Option<PathBuf> {
let mut entries = fs::read_dir(dir).ok()?;
let first = entries.next()?.ok()?;
if entries.next().is_some() {
return None;
}
let path = first.path();
path.is_dir().then_some(path)
}
fn is_empty_dir(dir: &Path) -> bool {
fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
}