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 开发中。
319 lines
11 KiB
Rust
319 lines
11 KiB
Rust
//! 归档解压: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())
|
||
}
|