Files
Starlight_Lancher/packages/app-lib/src/api/drop_classifier.rs
T
Disy ce0f41c973
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
fix: 修复实时日志与数据库切换可靠性
2026-09-27 14:18:25 +08:00

3603 lines
122 KiB
Rust

//! Unified content-type classifier for dropped / imported files.
//!
//! Determines what kind of Minecraft content a file or folder represents,
//! supporting launcher directories, mod JARs, resource packs, world saves,
//! litematic files, shader packs, and more.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::api::pack::detect::LocalPackFormat;
use crate::api::pack::import::ImportLauncherType;
use crate::mod_metadata::manifest::read_jar_manifest;
use crate::state::{ModrinthProjectId, ModrinthVersionId};
/// Maximum number of items allowed in a ZIP before we classify it as "ZIP
/// with many items" rather than "single file/folder wrapped in ZIP".
const ZIP_TOP_LEVEL_LIMIT: usize = 200;
/// Entry-name segments that are never descended into during classification.
/// They are operating-system noise, not Minecraft content.
const NOISE_ENTRY_NAMES: [&str; 4] =
["__MACOSX", ".git", "Thumbs.db", ".DS_Store"];
/// Result of classifying a file path dropped / imported by the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DroppedItemType {
/// A recognised third-party launcher root folder.
Launcher {
launcher_type: ImportLauncherType,
base_path: PathBuf,
/// For ZIP sources, the virtual folder inside the archive where the
/// launcher markers matched (e.g. `.minecraft`). The frontend
/// extracts the archive and scans this subfolder. `None` for real
/// folders and files.
inner_base: Option<String>,
},
/// HMCL launcher with separate launcher and data directories.
HmclLauncher {
launcher_dir: PathBuf,
data_dir: PathBuf,
},
/// A mod JAR file.
Mod { file_path: PathBuf },
/// A `.litematic` or `.schematic` file.
Litematic { file_path: PathBuf },
/// A resource pack and/or data pack.
ResourcePack {
file_path: PathBuf,
candidates: Vec<String>,
inner_base: Option<String>,
},
/// A shader pack.
ShaderPack {
file_path: PathBuf,
inner_base: Option<String>,
},
/// A Minecraft world save folder or archive.
WorldSave {
file_path: PathBuf,
inner_base: Option<String>,
},
/// A shortcut / symlink that was resolved to another item type.
ShortcutResolved {
original: PathBuf,
resolved_to: Box<DroppedItemType>,
},
/// A modpack archive (.mrpack, CurseForge, MultiMC, etc.).
Modpack { file_path: PathBuf },
/// Multiple import candidates found in one source, ordered by priority.
Multiple {
file_path: PathBuf,
candidates: Vec<DroppedCandidate>,
},
/// Could not be classified.
Unknown { reason: String },
}
/// One importable candidate inside a multi-candidate classification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DroppedCandidate {
pub item_type: String,
pub file_path: PathBuf,
pub inner_base: Option<String>,
pub candidates: Vec<String>,
}
/// Classify a dropped file or folder path into a `DroppedItemType`.
///
/// The classification follows a strict priority order: shortcut resolution,
/// ZIP / EXE / JAR detection, then directory and file fallbacks.
/// Returns `Unknown` instead of panicking on any error.
pub fn classify_dropped_item(path: &Path) -> DroppedItemType {
classify_dropped_item_inner(path, 0, true)
}
/// Like [`classify_dropped_item`] but never unpacks nested archives: when a
/// nested archive would have to be unpacked to decide the type, the result
/// is `Unknown` with a reason reporting the total nested size, so the caller
/// can confirm the (potentially slow) unpack with the user first.
pub fn classify_dropped_item_without_nested_unpack(
path: &Path,
) -> DroppedItemType {
classify_dropped_item_inner(path, 0, false)
}
/// Maximum number of shortcut hops followed before giving up. Guards against
/// shortcut / symlink cycles (e.g. `a.lnk` → `b.lnk` → `a.lnk`) that would
/// otherwise recurse until the stack overflows.
const MAX_SHORTCUT_HOPS: u32 = 8;
fn classify_dropped_item_inner(
path: &Path,
shortcut_depth: u32,
allow_nested_unpack: bool,
) -> DroppedItemType {
if !path.exists() {
let reason = "Path does not exist".to_string();
tracing::warn!(
"Classification failed for '{}': {reason}",
path.display()
);
return DroppedItemType::Unknown { reason };
}
if let Some(resolved) =
crate::util::resolve_shortcut::resolve_shortcut(path, 3)
&& resolved != path
&& shortcut_depth < MAX_SHORTCUT_HOPS
{
let inner = classify_dropped_item_inner(
&resolved,
shortcut_depth + 1,
allow_nested_unpack,
);
return DroppedItemType::ShortcutResolved {
original: path.to_path_buf(),
resolved_to: Box::new(inner),
};
}
if is_zip_path(path) {
return classify_zip_path(path, allow_nested_unpack);
}
if let Some(ext) = path.extension()
&& ext.eq_ignore_ascii_case("exe")
{
return classify_launcher_exe(path);
}
if let Some(ext) = path.extension()
&& ext.eq_ignore_ascii_case("disabled")
{
return classify_disabled(path, allow_nested_unpack);
}
if let Some(ext) = path.extension()
&& ext.eq_ignore_ascii_case("jar")
{
return classify_jar(path);
}
// Step 5: Directory.
if path.is_dir() {
let result = classify_folder(path, allow_nested_unpack);
tracing::debug!(
"classify_dropped_item: directory path={} result={:?}",
path.display(),
result
);
return result;
}
let result = classify_file(path);
tracing::debug!(
"classify_dropped_item: file path={} result={:?}",
path.display(),
result
);
result
}
/// Returns true when the path points to a ZIP-family archive (.zip / .mrpack).
fn is_zip_path(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
ext.eq_ignore_ascii_case("zip")
|| ext.eq_ignore_ascii_case("mrpack")
})
}
/// Classify a `.disabled` file by treating it as the underlying file type
/// (e.g. `mod.jar.disabled` → Mod, `pack.zip.disabled` → ZIP). The original
/// path is kept in the result — no path rewrite happens.
fn classify_disabled(
path: &Path,
allow_nested_unpack: bool,
) -> DroppedItemType {
let Some(stem) = path.file_stem() else {
return classify_file(path);
};
let Some(stem_str) = stem.to_str() else {
return classify_file(path);
};
let Some(underlying_ext) = stem_str.rsplit('.').next() else {
return classify_file(path);
};
if underlying_ext.eq_ignore_ascii_case("jar") {
// classify_jar reads the original file content (still valid).
return classify_jar(path);
}
if underlying_ext.eq_ignore_ascii_case("zip")
|| underlying_ext.eq_ignore_ascii_case("mrpack")
{
// The file content is still a valid archive, so the ZIP pipeline runs
// on the original path.
return classify_zip_path(path, allow_nested_unpack);
}
// Other .disabled extensions fall through to file classification.
classify_file(path)
}
// ─── ZIP archive classification ─────────────────────────────────────────────
/// Maximum depth for recursively classifying ZIP contents. Folder nesting
/// inside the archive and nested ZIP files both count towards the limit.
const MAX_ZIP_NESTING_DEPTH: u32 = 5;
/// Entry names of a ZIP archive, enumerated once without extracting anything.
///
/// Folder nesting is handled virtually: each recursion level reuses this set
/// and only narrows the path prefix, so no directory tree is written to disk
/// just to classify it.
struct ZipEntrySet {
/// Decoded entry names exactly as stored (directories keep the trailing
/// `/`), used for pack-manifest detection.
names: Vec<String>,
/// File paths without a trailing slash.
files: std::collections::HashSet<String>,
/// Directory paths without a trailing slash.
dirs: std::collections::HashSet<String>,
/// Whether any entry is encrypted (password-protected) and therefore
/// unreadable during content confirmation.
has_encrypted: bool,
}
impl ZipEntrySet {
fn from_archive<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<ZipEntrySet, String> {
let mut set = ZipEntrySet {
names: Vec::new(),
files: std::collections::HashSet::new(),
dirs: std::collections::HashSet::new(),
has_encrypted: false,
};
for i in 0..archive.len() {
let Ok(entry) = archive.by_index_raw(i) else {
continue;
};
if entry.encrypted() {
set.has_encrypted = true;
}
// Normalize Windows-authored separators so every lookup and the
// virtual folder tree agree on one representation.
let name = crate::api::pack::detect::decode_zip_entry_name(
entry.name_raw(),
)
.replace('\\', "/");
if name.is_empty() {
continue;
}
set.names.push(name.clone());
if name.ends_with('/') {
let normalized = name.trim_end_matches('/').to_string();
if !normalized.is_empty() {
set.dirs.insert(normalized);
}
} else {
set.files.insert(name);
}
}
Ok(set)
}
/// Whether a file exists at `{base}{relative}`.
fn has_file(&self, base: &str, relative: &str) -> bool {
self.files.contains(&format!("{base}{relative}"))
}
/// Whether `versions/<id>/<id>.json` exists under `base` (vanilla
/// launcher instance marker).
fn has_version_json(&self, base: &str) -> bool {
self.files.iter().any(|path| {
let Some(rest) = path.strip_prefix(base) else {
return false;
};
let parts: Vec<&str> = rest.split('/').collect();
parts.len() == 3
&& parts[0] == "versions"
&& !parts[1].is_empty()
&& parts[1]
== parts[2].strip_suffix(".json").unwrap_or_default()
})
}
/// Whether `axolotl_config.json` exists under `base`.
fn has_axolotl_config(&self, base: &str) -> bool {
self.has_file(base, "axolotl_config.json")
}
/// Whether a direct child folder under `base` has `axolotl_config.json`
/// (a container of Axolotl instances).
fn has_direct_axolotl_config(&self, base: &str) -> bool {
self.child_folders(base).iter().any(|child| {
self.has_file(&format!("{base}{child}/"), "axolotl_config.json")
})
}
/// Whether direct child folders under `base` look like version folders
/// (`<id>/<id>.json`). A base folder named `versions`, at least two
/// matching children, or a matching child with the same-name `.jar`
/// makes the classification specific enough to avoid false positives.
fn has_direct_version_json(&self, base: &str) -> bool {
let mut matching: Vec<String> = Vec::new();
for child in self.child_folders(base) {
let child_base = format!("{base}{child}/");
if self.has_file(&child_base, &format!("{child}.json")) {
matching.push(child);
}
}
if matching.is_empty() {
return false;
}
let base_name = base
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or_default();
if base_name.eq_ignore_ascii_case("versions") {
return true;
}
if matching.len() >= 2 {
return true;
}
matching.iter().any(|child| {
let child_base = format!("{base}{child}/");
self.has_file(&child_base, &format!("{child}.jar"))
})
}
/// Whether `instances/<id>/instance.cfg` exists under `base`
/// (MultiMC / Prism launcher instance marker).
fn has_mmc_instance(&self, base: &str) -> bool {
self.files.iter().any(|path| {
let Some(rest) = path.strip_prefix(base) else {
return false;
};
let parts: Vec<&str> = rest.split('/').collect();
parts.len() == 3
&& parts[0] == "instances"
&& parts[2] == "instance.cfg"
&& !parts[1].is_empty()
})
}
/// Whether `mods/` contains at least one `.jar` under `base`.
fn has_mods_jar(&self, base: &str) -> bool {
self.files.iter().any(|path| {
let Some(rest) = path.strip_prefix(base) else {
return false;
};
let parts: Vec<&str> = rest.split('/').collect();
parts.len() == 2
&& parts[0] == "mods"
&& parts[1].to_lowercase().ends_with(".jar")
})
}
/// Whether at least two direct child folders under `base` each look like
/// an instance folder (root jar+json, `versions/<id>` or `mods/*.jar`).
/// PCL-style launcher folders bundle several version folders this way;
/// classification reports the common parent and the instance scan
/// enumerates the children.
fn has_multiple_instance_children(&self, base: &str) -> bool {
let mut count = 0;
for child in self.child_folders(base) {
let child_base = format!("{base}{child}/");
if self.has_version_json(&child_base)
|| self.has_direct_version_json(&child_base)
|| self.has_mods_jar(&child_base)
|| self.has_root_jar_and_json(&child_base)
{
count += 1;
if count >= 2 {
return true;
}
}
}
false
}
/// Whether `shaders/` below `base` contains at least one entry (file or
/// folder, of any kind). An empty `shaders/` directory is not a shader
/// pack.
fn has_populated_shaders_dir(&self, base: &str) -> bool {
let prefix = format!("{base}shaders/");
self.files.iter().any(|path| path.starts_with(&prefix))
|| self.dirs.iter().any(|dir| dir.starts_with(&prefix))
}
/// Whether `base` holds both a `.jar` and a `.json` file (modded
/// instance root marker).
fn has_root_jar_and_json(&self, base: &str) -> bool {
let mut has_jar = false;
let mut has_json = false;
for path in &self.files {
let Some(rest) = path.strip_prefix(base) else {
continue;
};
if rest.contains('/') {
continue;
}
if rest.to_lowercase().ends_with(".jar") {
has_jar = true;
}
if rest.to_lowercase().ends_with(".json") {
has_json = true;
}
}
has_jar && has_json
}
/// Direct child directories under `base`, without the trailing slash.
fn child_folders(&self, base: &str) -> Vec<String> {
let mut children: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<&str> =
std::collections::HashSet::new();
for dir in &self.dirs {
if let Some(rest) = dir.strip_prefix(base) {
let first = rest.split('/').next().unwrap_or_default();
if !first.is_empty()
&& !is_noise_entry(first)
&& seen.insert(first)
{
children.push(first.to_string());
}
}
}
for path in &self.files {
if let Some(rest) = path.strip_prefix(base)
&& let Some((first, _)) = rest.split_once('/')
&& !first.is_empty()
&& !is_noise_entry(first)
&& seen.insert(first)
{
children.push(first.to_string());
}
}
children.sort();
children
}
/// Whether `base` has a direct child folder with the given name.
fn has_child_folder(&self, base: &str, name: &str) -> bool {
self.child_folders(base)
.iter()
.any(|child| child.eq_ignore_ascii_case(name))
}
/// Direct child files under `base` that are themselves ZIP-family
/// archives (.zip / .mrpack).
fn nested_zip_files(&self, base: &str) -> Vec<String> {
let mut zips: Vec<String> = Vec::new();
for path in &self.files {
let Some(rest) = path.strip_prefix(base) else {
continue;
};
if rest.contains('/') {
continue;
}
if rest.to_lowercase().ends_with(".zip")
|| rest.to_lowercase().ends_with(".mrpack")
{
zips.push(rest.to_string());
}
}
zips.sort();
zips
}
}
/// Whether an entry-name segment is operating-system noise that must not be
/// recursed into (macOS resource forks, VCS metadata, thumbnails).
fn is_noise_entry(name: &str) -> bool {
NOISE_ENTRY_NAMES
.iter()
.any(|noise| name.eq_ignore_ascii_case(noise))
}
/// Direct child folders and ZIP archives under `path`, sorted for stable
/// classification and without symlinks or OS noise entries.
fn sorted_folder_children(path: &Path) -> Vec<PathBuf> {
let mut children = Vec::new();
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if is_noise_entry(&name) {
continue;
}
let child = entry.path();
if file_type.is_dir() || is_zip_path(&child) {
children.push(child);
}
}
}
children.sort();
children
}
fn resource_pack_candidates(entries: &ZipEntrySet, base: &str) -> Vec<String> {
let mut candidates = Vec::new();
if entries.has_child_folder(base, "data") {
candidates.push("data_pack".to_string());
}
if entries.has_child_folder(base, "assets") {
candidates.push("resource_pack".to_string());
}
candidates
}
/// Whether the filesystem holding `dir` has at least `required` bytes free.
/// Returns `true` when the free space cannot be determined so the hard size
/// cap still applies as a fallback.
fn temp_dir_has_space(dir: &Path, required: u64) -> bool {
let disks = sysinfo::Disks::new_with_refreshed_list();
let mut best: Option<(usize, u64)> = None;
for disk in disks.list() {
let mount = disk.mount_point();
if dir.starts_with(mount)
&& best.is_none_or(|(len, _)| mount.as_os_str().len() > len)
{
best = Some((mount.as_os_str().len(), disk.available_space()));
}
}
match best {
Some((_, available)) => available >= required,
None => true,
}
}
/// Classify a ZIP file with the layered, recursive flow:
///
/// 1. Modpack manifests (modrinth.index.json, manifest.json, ...)
/// 2. Compressed instances / `.minecraft` (versions, mods, launcher cfg)
/// 3. Resource packs (`pack.mcmeta`)
/// 4. Shader packs (`shaders/`)
/// 5. World saves (`level.dat`)
/// 6. When none match, descend into nested folders and nested ZIP files,
/// treating each as the new root, up to [`MAX_ZIP_NESTING_DEPTH`] levels.
///
/// Everything runs on entry names; nothing is extracted during
/// classification.
fn classify_zip_path(
path: &Path,
allow_nested_unpack: bool,
) -> DroppedItemType {
let mut nested_unpack_bytes: u64 = 0;
classify_zip_file_at_depth(
path,
0,
allow_nested_unpack,
&mut nested_unpack_bytes,
)
}
fn classify_zip_file_at_depth(
path: &Path,
depth: u32,
allow_nested_unpack: bool,
nested_unpack_bytes: &mut u64,
) -> DroppedItemType {
let Ok(file) = std::fs::File::open(path) else {
return DroppedItemType::Unknown {
reason: "Cannot open ZIP file".to_string(),
};
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return DroppedItemType::Unknown {
reason: "File is not a valid ZIP archive".to_string(),
};
};
let entry_set = match ZipEntrySet::from_archive(&mut archive) {
Ok(set) => set,
Err(reason) => return DroppedItemType::Unknown { reason },
};
classify_zip_entries(
path,
&mut archive,
&entry_set,
depth,
"",
allow_nested_unpack,
nested_unpack_bytes,
)
}
/// Recursive core of ZIP classification for one virtual root (`base`).
fn classify_zip_entries<R: std::io::Read + std::io::Seek>(
result_path: &Path,
archive: &mut zip::ZipArchive<R>,
entries: &ZipEntrySet,
depth: u32,
base: &str,
allow_nested_unpack: bool,
nested_unpack_bytes: &mut u64,
) -> DroppedItemType {
// 1. Modpack manifests. Checked first because a modpack can carry
// overrides that look like resource packs, shader packs or worlds.
match crate::api::pack::detect::detect_at_base(
archive,
&entries.names,
base,
) {
Ok(Some(detected)) => {
let is_pack = matches!(
detected.format,
LocalPackFormat::Mrpack
| LocalPackFormat::CurseForge
| LocalPackFormat::Mcbbs
| LocalPackFormat::Hmcl
| LocalPackFormat::MmcExport
| LocalPackFormat::LauncherBundled
);
if is_pack {
tracing::debug!(
"ZIP classify: modpack {:?} at base {:?} — {}",
detected.format,
base,
result_path.display()
);
return DroppedItemType::Modpack {
file_path: result_path.to_path_buf(),
};
}
}
Ok(None) => {}
Err(error) => {
tracing::warn!(
"ZIP classify: pack detection failed at base {:?}: {error}",
base
);
}
}
// 2. Compressed instances / `.minecraft`. Checked before the content
// markers because instances embed resource packs, shader packs and
// worlds.
let launcher_type = if entries.has_axolotl_config(base)
|| entries.has_direct_axolotl_config(base)
{
Some(ImportLauncherType::Axolotl)
} else if entries.has_file(base, "multimc.cfg") {
Some(ImportLauncherType::MultiMC)
} else if entries.has_file(base, "prismlauncher.cfg") {
Some(ImportLauncherType::PrismLauncher)
} else if entries.has_mmc_instance(base)
|| entries.has_file(base, "instance.cfg")
{
Some(ImportLauncherType::MultiMC)
} else if entries.has_version_json(base)
|| entries.has_direct_version_json(base)
|| entries.has_mods_jar(base)
|| entries.has_root_jar_and_json(base)
{
Some(ImportLauncherType::Generic)
} else if entries.has_multiple_instance_children(base) {
Some(ImportLauncherType::Generic)
} else if entries.has_file(base, ".hmcl/config/launcher-settings.json") {
Some(ImportLauncherType::HMCL)
} else {
None
};
if let Some(launcher_type) = launcher_type {
tracing::debug!(
"ZIP classify: instance {:?} at base {:?} — {}",
launcher_type,
base,
result_path.display()
);
return DroppedItemType::Launcher {
launcher_type,
base_path: result_path.to_path_buf(),
inner_base: zip_inner_base(base),
};
}
// 3-5. Content markers, matched by name and structure at the current
// root: `pack.mcmeta`, a populated `shaders/` folder, or `level.dat`.
// Nested folders are handled by the recursion below, which re-runs
// this whole flow with the folder as the new root.
if entries.has_file(base, "pack.mcmeta") {
let candidates = resource_pack_candidates(entries, base);
if !candidates.is_empty() {
tracing::debug!(
"ZIP classify: pack.mcmeta with candidates {candidates:?} at base {:?} — {}",
base,
result_path.display()
);
return DroppedItemType::ResourcePack {
file_path: result_path.to_path_buf(),
candidates,
inner_base: zip_inner_base(base),
};
}
}
if entries.has_populated_shaders_dir(base) {
tracing::debug!(
"ZIP classify: shaders/ → ShaderPack at base {:?} — {}",
base,
result_path.display()
);
return DroppedItemType::ShaderPack {
file_path: result_path.to_path_buf(),
inner_base: zip_inner_base(base),
};
}
if entries.has_file(base, "level.dat") {
tracing::debug!(
"ZIP classify: level.dat → WorldSave at base {:?} — {}",
base,
result_path.display()
);
return DroppedItemType::WorldSave {
file_path: result_path.to_path_buf(),
inner_base: zip_inner_base(base),
};
}
// 6. Recurse into nested folders and nested ZIP files.
if depth < MAX_ZIP_NESTING_DEPTH {
for child in entries.child_folders(base) {
if child == "__MACOSX" {
continue;
}
let child_base = format!("{base}{child}/");
let result = classify_zip_entries(
result_path,
archive,
entries,
depth + 1,
&child_base,
allow_nested_unpack,
nested_unpack_bytes,
);
if !matches!(result, DroppedItemType::Unknown { .. }) {
return result;
}
}
for nested in entries.nested_zip_files(base) {
if !allow_nested_unpack {
// First pass: report the nested archive instead of unpacking
// it; the caller confirms with the user before staging.
if let Some(size) = nested_zip_uncompressed_size(
archive,
&format!("{base}{nested}"),
) {
*nested_unpack_bytes += size;
}
continue;
}
let result = classify_nested_zip(
result_path,
archive,
depth + 1,
&format!("{base}{nested}"),
allow_nested_unpack,
);
if !matches!(result, DroppedItemType::Unknown { .. }) {
return result;
}
}
} else {
tracing::debug!(
"ZIP classify: nesting depth limit reached at base={base:?} — {}",
result_path.display()
);
return DroppedItemType::Unknown {
reason: format!(
"Archive nesting is too deep to analyze (limit {MAX_ZIP_NESTING_DEPTH} levels)"
),
};
}
tracing::debug!(
"ZIP classify: inconclusive at depth={depth} base={base:?} — {}",
result_path.display()
);
if *nested_unpack_bytes > 0 {
DroppedItemType::Unknown {
reason: format!(
"Archive contains nested archives that must be unpacked to analyze (total {nested_unpack_bytes} bytes)"
),
}
} else if entries.has_encrypted {
DroppedItemType::Unknown {
reason:
"Archive contains encrypted files and cannot be fully analyzed"
.to_string(),
}
} else {
DroppedItemType::Unknown {
reason:
"ZIP archive requires extraction to determine content type (large archives may take a while)"
.to_string(),
}
}
}
/// Reads a nested ZIP entry once into a temporary file and classifies it
/// with the same layered flow, remapping the result path back to the outer
/// archive so no temp path leaks into the classification result.
fn classify_nested_zip<R: std::io::Read + std::io::Seek>(
result_path: &Path,
archive: &mut zip::ZipArchive<R>,
depth: u32,
entry_path: &str,
allow_nested_unpack: bool,
) -> DroppedItemType {
let Some(index) =
crate::api::pack::detect::find_entry_index(archive, entry_path)
.ok()
.flatten()
else {
return DroppedItemType::Unknown {
reason: format!("Cannot find nested archive entry {entry_path}"),
};
};
let Ok(mut entry) = archive.by_index(index) else {
return DroppedItemType::Unknown {
reason: format!("Cannot read nested archive entry {entry_path}"),
};
};
let temp_dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(error) => {
return DroppedItemType::Unknown {
reason: format!(
"Failed to create temporary directory: {error}"
),
};
}
};
if !temp_dir_has_space(temp_dir.path(), entry.size()) {
return DroppedItemType::Unknown {
reason: format!(
"Not enough free disk space to stage nested archive {entry_path}"
),
};
}
let nested_path = temp_dir.path().join("nested.zip");
if let Err(error) = std::fs::File::create(&nested_path)
.and_then(|mut output| std::io::copy(&mut entry, &mut output))
{
return DroppedItemType::Unknown {
reason: format!(
"Failed to stage nested archive {entry_path}: {error}"
),
};
}
let mut nested_unpack_bytes: u64 = 0;
let result = classify_zip_file_at_depth(
&nested_path,
depth,
allow_nested_unpack,
&mut nested_unpack_bytes,
);
let remap_inner = |inner_base: Option<String>| -> Option<String> {
let combined = match inner_base {
Some(inner) => format!("{entry_path}/{inner}"),
None => entry_path.trim_end_matches('/').to_string(),
};
sanitize_inner_base(&combined)
};
match result {
DroppedItemType::Unknown { reason } => DroppedItemType::Unknown {
reason: format!("nested archive {entry_path}: {reason}"),
},
DroppedItemType::WorldSave { inner_base, .. } => {
DroppedItemType::WorldSave {
file_path: result_path.to_path_buf(),
inner_base: remap_inner(inner_base),
}
}
DroppedItemType::ResourcePack {
candidates,
inner_base,
..
} => DroppedItemType::ResourcePack {
file_path: result_path.to_path_buf(),
candidates,
inner_base: remap_inner(inner_base),
},
DroppedItemType::ShaderPack { inner_base, .. } => {
DroppedItemType::ShaderPack {
file_path: result_path.to_path_buf(),
inner_base: remap_inner(inner_base),
}
}
DroppedItemType::Modpack { .. } => DroppedItemType::Modpack {
file_path: result_path.to_path_buf(),
},
DroppedItemType::Launcher { launcher_type, .. } => {
DroppedItemType::Launcher {
launcher_type,
base_path: result_path.to_path_buf(),
// The launcher lives inside a nested archive; the frontend
// cannot scan it until that archive is extracted too, so no
// inner base is advertised.
inner_base: None,
}
}
other => other,
}
}
/// Uncompressed size of a nested archive entry, read from its metadata
/// without unpacking anything.
fn nested_zip_uncompressed_size<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
entry_path: &str,
) -> Option<u64> {
let index = crate::api::pack::detect::find_entry_index(archive, entry_path)
.ok()
.flatten()?;
archive.by_index_raw(index).ok().map(|entry| entry.size())
}
/// Extracts a ZIP archive to a temporary directory and classifies its contents
/// by examining the extracted files and folders.
///
/// This is a potentially **long-running** operation — the caller MUST first
/// confirm with the user before calling this function.
pub fn classify_zip_with_extraction(path: &Path) -> DroppedItemType {
let Ok(file) = std::fs::File::open(path) else {
return DroppedItemType::Unknown {
reason: "Cannot open ZIP file".to_string(),
};
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return DroppedItemType::Unknown {
reason: "File is not a valid ZIP archive".to_string(),
};
};
let entry_set = match ZipEntrySet::from_archive(&mut archive) {
Ok(set) => set,
Err(reason) => return DroppedItemType::Unknown { reason },
};
let child_folders = entry_set.child_folders("");
let root_files: Vec<&str> = entry_set
.files
.iter()
.filter(|path| !path.contains('/'))
.map(|path| path.as_str())
.collect();
let top_level_count = child_folders.len() + root_files.len();
if top_level_count == 0 {
return DroppedItemType::Unknown {
reason: "Empty zip file".to_string(),
};
}
if top_level_count > ZIP_TOP_LEVEL_LIMIT {
return DroppedItemType::Unknown {
reason: "ZIP archive has too many top-level entries".to_string(),
};
}
// Create temporary directory for extraction.
let temp_dir = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
return DroppedItemType::Unknown {
reason: format!("Failed to create temporary directory: {e}"),
};
}
};
// Extract everything.
extract_all(&mut archive, temp_dir.path());
tracing::debug!(
"classify_zip_with_extraction: extracted {} top-level items for {}",
top_level_count,
path.display()
);
// Classify the extracted contents: a single top-level item is classified
// directly, otherwise the extraction root is treated as a folder.
if child_folders.len() == 1 && root_files.is_empty() {
classify_dropped_item(&temp_dir.path().join(&child_folders[0]))
} else if root_files.len() == 1 && child_folders.is_empty() {
classify_dropped_item(&temp_dir.path().join(root_files[0]))
} else {
classify_folder_content(temp_dir.path(), true)
}
// temp_dir is dropped here, cleaning up the extracted files automatically.
}
fn extract_all(archive: &mut zip::ZipArchive<std::fs::File>, base_dir: &Path) {
// First pass: collect entry metadata while the archive is mutable-borrowed.
let entries: Vec<(usize, String, bool)> = (0..archive.len())
.filter_map(|i| {
let entry = archive.by_index_raw(i).ok()?;
let name = entry.name().to_string();
if name.is_empty() {
None
} else {
Some((i, name.clone(), name.ends_with('/')))
}
})
.collect();
// Second pass: extract. The collect() above has released the mutable
// borrow, so we can call by_index() here.
for (index, name, is_dir) in &entries {
if archive
.by_index_raw(*index)
.ok()
.is_some_and(|e| e.encrypted())
{
tracing::warn!(
"extract_all: skipping encrypted ZIP entry '{name}'"
);
continue;
}
// Reject entries that would escape the extraction directory.
let Some(safe_name) = sanitize_zip_entry_name(name) else {
tracing::warn!(
"extract_all: skipping unsafe ZIP entry '{}' (path traversal)",
name
);
continue;
};
let out_path = base_dir.join(&safe_name);
if *is_dir {
let _ = std::fs::create_dir_all(&out_path);
} else if let Some(parent) = out_path.parent() {
let _ = std::fs::create_dir_all(parent);
if let Ok(mut reader) = archive.by_index(*index)
&& let Ok(mut writer) = std::fs::File::create(&out_path)
{
let _ = std::io::copy(&mut reader, &mut writer);
}
}
}
}
/// Extract a ZIP archive into `dest_dir`, skipping unsafe entries (path
/// traversal, absolute paths, drive letters) and macOS resource forks.
///
/// Used by the app to materialize a compressed launcher folder before
/// scanning and importing instances. The caller owns the temporary
/// directory lifecycle.
pub fn extract_zip_to_dir(
zip_path: &Path,
dest_dir: &Path,
) -> Result<(), String> {
let file = std::fs::File::open(zip_path).map_err(|e| {
format!("Cannot open ZIP file '{}': {e}", zip_path.display())
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
format!("Invalid ZIP archive '{}': {e}", zip_path.display())
})?;
let entries: Vec<(usize, String, bool)> = (0..archive.len())
.filter_map(|i| {
let entry = archive.by_index_raw(i).ok()?;
let name = crate::api::pack::detect::decode_zip_entry_name(
entry.name_raw(),
)
.replace('\\', "/");
if name.is_empty() || name.starts_with("__MACOSX") {
return None;
}
Some((i, name.clone(), name.ends_with('/')))
})
.collect();
let mut skipped = 0usize;
for (index, name, is_dir) in entries {
if archive
.by_index_raw(index)
.ok()
.is_some_and(|e| e.encrypted())
{
tracing::warn!(
"extract_zip_to_dir: skipping encrypted ZIP entry '{name}'"
);
skipped += 1;
continue;
}
let Some(safe_name) = sanitize_zip_entry_name(&name) else {
tracing::warn!(
"extract_zip_to_dir: skipping unsafe ZIP entry '{name}' (path traversal)"
);
skipped += 1;
continue;
};
if safe_name.as_os_str().is_empty() {
skipped += 1;
continue;
}
let out_path = dest_dir.join(&safe_name);
// A single unreadable, locked or oddly-named entry must not abort the
// whole import; skip it and keep going so the rest of the launcher
// folder still lands on disk.
let result = if is_dir {
std::fs::create_dir_all(&out_path).map(|_| ())
} else if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).and_then(|_| {
let mut reader =
archive.by_index(index).map_err(std::io::Error::other)?;
let mut writer = std::fs::File::create(&out_path)?;
std::io::copy(&mut reader, &mut writer).map(|_| ())
})
} else {
Ok(())
};
if let Err(error) = result {
tracing::warn!(
"extract_zip_to_dir: skipping unextractable entry '{name}': {error}"
);
skipped += 1;
}
}
if skipped > 0 {
tracing::warn!(
"extract_zip_to_dir: skipped {skipped} of {} entries",
archive.len()
);
}
if skipped == archive.len() && !archive.is_empty() {
return Err(format!(
"No ZIP entries could be extracted ({skipped} failed)"
));
}
Ok(())
}
/// Normalize a ZIP entry name into a safe relative path that stays inside the
/// extraction directory. Returns `None` for absolute paths or entries
/// containing `..` (zip-slip protection).
fn sanitize_zip_entry_name(name: &str) -> Option<PathBuf> {
// The ZIP spec mandates `/` separators, but tolerate backslashes from
// Windows-authored archives.
let normalized = name.replace('\\', "/");
let path = Path::new(&normalized);
if path.is_absolute() {
return None;
}
let mut safe = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Normal(part) => safe.push(part),
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => return None,
}
}
Some(safe)
}
// ─── Step 3: Launcher EXE ──────────────────────────────────────────────────
fn classify_launcher_exe(path: &Path) -> DroppedItemType {
if let Some(parent) = path.parent() {
match crate::api::pack::import::pe_info::folder_has_product_result(
parent,
"Plain Craft Launcher",
) {
Ok(true) => {
if crate::api::pack::import::config_exists() {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::PCL2CE,
base_path: parent.to_path_buf(),
inner_base: None,
};
}
if crate::api::pack::import::read_pcl_registry().is_some() {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::PCL2,
base_path: parent.to_path_buf(),
inner_base: None,
};
}
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::PCL2,
base_path: parent.to_path_buf(),
inner_base: None,
};
}
Ok(false) => {}
Err(_) => {}
}
match crate::api::pack::import::pe_info::folder_has_product_result(
parent,
"Hello Minecraft! Launcher",
) {
Ok(true) => {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::HMCL,
base_path: parent.to_path_buf(),
inner_base: None,
};
}
Ok(false) => {}
Err(_) => {}
}
}
DroppedItemType::Unknown {
reason: format!("Unrecognised executable: {}", path.display()),
}
}
// ─── Step 4: JAR file ──────────────────────────────────────────────────────
fn classify_jar(path: &Path) -> DroppedItemType {
let manifest = read_jar_manifest(path);
if let Some(ref mf) = manifest {
// HMCL launcher JAR.
if mf.main_class.as_deref() == Some("org.jackhuang.hmcl.Main") {
if let Some(parent) = path.parent()
&& let Some(data_dir) =
crate::api::pack::import::hmcl_config::find_hmcl_data_dir(
parent,
)
{
return DroppedItemType::HmclLauncher {
launcher_dir: parent.to_path_buf(),
data_dir,
};
}
// Found HMCL main class but no data dir — still classify as launcher.
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::HMCL,
base_path: path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default(),
inner_base: None,
};
}
}
// Otherwise, treat as a mod.
DroppedItemType::Mod {
file_path: path.to_path_buf(),
}
}
// ─── Step 5: Folder classification ─────────────────────────────────────────
fn classify_folder(path: &Path, allow_nested_unpack: bool) -> DroppedItemType {
classify_folder_content(path, allow_nested_unpack)
}
// ─── Step 6: File classification (non-JAR, non-EXE, non-ZIP) ───────────────
fn classify_file(path: &Path) -> DroppedItemType {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"litematic" | "schematic" => DroppedItemType::Litematic {
file_path: path.to_path_buf(),
},
_ => DroppedItemType::Unknown {
reason: format!("Unrecognised file type: {}", path.display()),
},
}
}
// ─── Step 7: Content-type detection for folders/extracted ZIPs ─────────────
pub(crate) fn classify_folder_content(
path: &Path,
allow_nested_unpack: bool,
) -> DroppedItemType {
let mut nested_unpack_bytes: u64 = 0;
classify_folder_content_inner(
path,
allow_nested_unpack,
0,
&mut nested_unpack_bytes,
)
}
fn classify_folder_content_inner(
path: &Path,
allow_nested_unpack: bool,
depth: u32,
nested_unpack_bytes: &mut u64,
) -> DroppedItemType {
// Check launcher signatures in priority order.
if is_axolotl_folder(path) {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Axolotl,
base_path: path.to_path_buf(),
inner_base: None,
};
}
if path.join("multimc.cfg").exists() {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::MultiMC,
base_path: path.to_path_buf(),
inner_base: None,
};
}
if path.join("prismlauncher.cfg").exists() {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::PrismLauncher,
base_path: path.to_path_buf(),
inner_base: None,
};
}
// MultiMC/Prism: check for instances/<sub>/instance.cfg pattern.
if let Ok(mut dir) = std::fs::read_dir(path.join("instances"))
&& dir.any(|e| {
e.ok()
.as_ref()
.is_some_and(|e| e.path().join("instance.cfg").exists())
})
{
// instance.cfg → MultiMC or Prism.
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::MultiMC,
base_path: path.to_path_buf(),
inner_base: None,
};
}
// HMCL portable mode.
let hmcl_config = path
.join(".hmcl")
.join("config")
.join("launcher-settings.json");
if hmcl_config.exists()
&& let Some(data_dir) =
crate::api::pack::import::hmcl_config::find_hmcl_data_dir(path)
{
return DroppedItemType::HmclLauncher {
launcher_dir: path.to_path_buf(),
data_dir,
};
}
// Step 7: Content-type detection for folders.
if let Some(result) = classify_world_save_folder(path) {
return result;
}
if let Some(result) = classify_resource_pack_folder(path) {
return result;
}
if let Some(result) = classify_shader_pack_folder(path) {
return result;
}
if is_launcher_instance_folder(path) {
return DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Generic,
base_path: path.to_path_buf(),
inner_base: None,
};
}
if depth >= MAX_ZIP_NESTING_DEPTH {
return DroppedItemType::Unknown {
reason: format!(
"Folder nesting is too deep to analyze (limit {MAX_ZIP_NESTING_DEPTH} levels)"
),
};
}
let children = sorted_folder_children(path);
for child in children {
let result = if child.is_dir() {
classify_folder_content_inner(
&child,
allow_nested_unpack,
depth + 1,
nested_unpack_bytes,
)
} else {
classify_zip_file_at_depth(
&child,
depth + 1,
allow_nested_unpack,
nested_unpack_bytes,
)
};
if !matches!(result, DroppedItemType::Unknown { .. }) {
return result;
}
}
if *nested_unpack_bytes > 0 {
return DroppedItemType::Unknown {
reason: format!(
"Archive contains nested archives that must be unpacked to analyze (total {nested_unpack_bytes} bytes)"
),
};
}
DroppedItemType::Unknown {
reason: format!(
"Unrecognised content: {}",
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string())
),
}
}
/// Classify a folder as a world save when it contains a `level.dat` file.
fn classify_world_save_folder(path: &Path) -> Option<DroppedItemType> {
path.join("level.dat")
.is_file()
.then(|| DroppedItemType::WorldSave {
file_path: path.to_path_buf(),
inner_base: None,
})
}
/// Classify a folder as a resource pack when it contains a `pack.mcmeta`.
fn classify_resource_pack_folder(path: &Path) -> Option<DroppedItemType> {
if !path.join("pack.mcmeta").is_file() {
return None;
}
let mut candidates = Vec::new();
if has_child_dir(path, "data") {
candidates.push("data_pack".to_string());
}
if has_child_dir(path, "assets") {
candidates.push("resource_pack".to_string());
}
(!candidates.is_empty()).then(|| DroppedItemType::ResourcePack {
file_path: path.to_path_buf(),
candidates,
inner_base: None,
})
}
/// Whether a direct child directory with the given name exists.
fn has_child_dir(path: &Path, name: &str) -> bool {
std::fs::read_dir(path).ok().is_some_and(|entries| {
entries.flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(name)
&& entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
})
})
}
/// Classify a folder as a shader pack when `shaders/` exists and contains at
/// least one entry (of any kind).
fn classify_shader_pack_folder(path: &Path) -> Option<DroppedItemType> {
let shaders = path.join("shaders");
if !shaders.is_dir() {
return None;
}
let has_any_entry = std::fs::read_dir(&shaders).ok()?.next().is_some();
has_any_entry.then(|| DroppedItemType::ShaderPack {
file_path: path.to_path_buf(),
inner_base: None,
})
}
/// Classify an input and attach any alternative content candidates found in
/// the same folder / archive tree. The primary hit is always first.
pub fn classify_dropped_item_with_candidates(
path: &Path,
allow_nested_unpack: bool,
) -> DroppedItemType {
let primary = classify_dropped_item_inner(path, 0, allow_nested_unpack);
if matches!(
primary,
DroppedItemType::Launcher { .. } | DroppedItemType::HmclLauncher { .. }
) {
return primary;
}
let mut alternatives =
collect_content_candidates(path, allow_nested_unpack);
if matches!(primary, DroppedItemType::Unknown { .. })
&& alternatives.is_empty()
{
return primary;
}
let mut all = Vec::new();
let primary_path = primary_path_of(&primary);
let primary_inner_base = relative_to(path, &primary_path);
if let Some(primary_candidate) =
candidate_from_result(&primary, primary_inner_base)
{
all.push(primary_candidate);
}
for candidate in alternatives.drain(..) {
if !all
.iter()
.any(|existing| same_candidate(existing, &candidate))
{
all.push(candidate);
}
}
if all.len() <= 1 && !matches!(primary, DroppedItemType::Unknown { .. }) {
return primary;
}
let has_primary =
!matches!(primary, DroppedItemType::Unknown { .. }) && !all.is_empty();
let primary_candidate = has_primary.then(|| all.remove(0));
all.sort_by(|a, b| {
candidate_priority(b)
.cmp(&candidate_priority(a))
.then_with(|| a.file_path.cmp(&b.file_path))
});
if let Some(primary_candidate) = primary_candidate {
all.insert(0, primary_candidate);
}
DroppedItemType::Multiple {
file_path: path.to_path_buf(),
candidates: all,
}
}
fn primary_path_of(item: &DroppedItemType) -> PathBuf {
match item {
DroppedItemType::Launcher { base_path, .. } => base_path.clone(),
DroppedItemType::HmclLauncher { launcher_dir, .. } => {
launcher_dir.clone()
}
DroppedItemType::Mod { file_path } => file_path.clone(),
DroppedItemType::Litematic { file_path } => file_path.clone(),
DroppedItemType::ResourcePack { file_path, .. } => file_path.clone(),
DroppedItemType::ShaderPack { file_path, .. } => file_path.clone(),
DroppedItemType::WorldSave { file_path, .. } => file_path.clone(),
DroppedItemType::Modpack { file_path } => file_path.clone(),
DroppedItemType::ShortcutResolved { resolved_to, .. } => {
primary_path_of(resolved_to)
}
DroppedItemType::Unknown { .. } | DroppedItemType::Multiple { .. } => {
PathBuf::new()
}
}
}
fn collect_content_candidates(
path: &Path,
allow_nested_unpack: bool,
) -> Vec<DroppedCandidate> {
if path.is_dir() {
let mut candidates = Vec::new();
collect_folder_candidates(
path,
allow_nested_unpack,
path,
0,
&mut candidates,
);
candidates
} else if is_zip_path(path) {
collect_zip_candidates(path, allow_nested_unpack)
} else {
Vec::new()
}
}
fn collect_folder_candidates(
path: &Path,
allow_nested_unpack: bool,
root: &Path,
depth: u32,
out: &mut Vec<DroppedCandidate>,
) {
if depth >= MAX_ZIP_NESTING_DEPTH {
return;
}
let inner_base = relative_to(root, path);
if let Some(result) = classify_world_save_folder(path) {
push_candidate(out, candidate_from_result(&result, inner_base.clone()));
}
if let Some(result) = classify_resource_pack_folder(path) {
push_candidate(out, candidate_from_result(&result, inner_base.clone()));
}
if let Some(result) = classify_shader_pack_folder(path) {
push_candidate(out, candidate_from_result(&result, inner_base));
}
let children = sorted_folder_children(path);
for child in children {
if child.is_dir() {
collect_folder_candidates(
&child,
allow_nested_unpack,
root,
depth + 1,
out,
);
} else {
let result = classify_zip_path(&child, allow_nested_unpack);
let child_base = relative_to(root, &child);
push_candidate(out, candidate_from_result(&result, child_base));
}
}
}
fn collect_zip_candidates(
path: &Path,
allow_nested_unpack: bool,
) -> Vec<DroppedCandidate> {
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let Ok(entries) = ZipEntrySet::from_archive(&mut archive) else {
return Vec::new();
};
let mut candidates = Vec::new();
collect_zip_candidates_at(
path,
&mut archive,
&entries,
"",
0,
allow_nested_unpack,
&mut candidates,
);
candidates
}
fn collect_zip_candidates_at<R: std::io::Read + std::io::Seek>(
result_path: &Path,
archive: &mut zip::ZipArchive<R>,
entries: &ZipEntrySet,
base: &str,
depth: u32,
allow_nested_unpack: bool,
out: &mut Vec<DroppedCandidate>,
) {
if depth >= MAX_ZIP_NESTING_DEPTH {
return;
}
let inner_base = zip_inner_base(base);
if entries.has_file(base, "pack.mcmeta") {
let candidates = resource_pack_candidates(entries, base);
if !candidates.is_empty() {
push_candidate(
out,
Some(DroppedCandidate {
item_type: "resource_pack".to_string(),
file_path: result_path.to_path_buf(),
inner_base: inner_base.clone(),
candidates,
}),
);
}
}
if entries.has_populated_shaders_dir(base) {
push_candidate(
out,
Some(DroppedCandidate {
item_type: "shader_pack".to_string(),
file_path: result_path.to_path_buf(),
inner_base: inner_base.clone(),
candidates: Vec::new(),
}),
);
}
if entries.has_file(base, "level.dat") {
push_candidate(
out,
Some(DroppedCandidate {
item_type: "world_save".to_string(),
file_path: result_path.to_path_buf(),
inner_base,
candidates: Vec::new(),
}),
);
}
for child in entries.child_folders(base) {
if is_noise_entry(&child) {
continue;
}
collect_zip_candidates_at(
result_path,
archive,
entries,
&format!("{base}{child}/"),
depth + 1,
allow_nested_unpack,
out,
);
}
if allow_nested_unpack {
for nested in entries.nested_zip_files(base) {
let result = classify_nested_zip(
result_path,
archive,
depth + 1,
&format!("{base}{nested}"),
true,
);
push_candidate(out, candidate_from_result(&result, None));
}
}
}
fn candidate_from_result(
result: &DroppedItemType,
outer_inner_base: Option<String>,
) -> Option<DroppedCandidate> {
let (item_type, file_path, candidates, inner_base) = match result {
DroppedItemType::Launcher {
base_path,
inner_base,
..
} => (
"launcher".to_string(),
base_path.clone(),
Vec::new(),
inner_base.clone(),
),
DroppedItemType::HmclLauncher { launcher_dir, .. } => (
"hmcl_launcher".to_string(),
launcher_dir.clone(),
Vec::new(),
None,
),
DroppedItemType::Mod { file_path } => {
("mod".to_string(), file_path.clone(), Vec::new(), None)
}
DroppedItemType::Litematic { file_path } => {
("litematic".to_string(), file_path.clone(), Vec::new(), None)
}
DroppedItemType::ResourcePack {
file_path,
candidates,
inner_base,
} => (
"resource_pack".to_string(),
file_path.clone(),
candidates.clone(),
inner_base.clone(),
),
DroppedItemType::ShaderPack {
file_path,
inner_base,
} => (
"shader_pack".to_string(),
file_path.clone(),
Vec::new(),
inner_base.clone(),
),
DroppedItemType::WorldSave {
file_path,
inner_base,
} => (
"world_save".to_string(),
file_path.clone(),
Vec::new(),
inner_base.clone(),
),
DroppedItemType::Modpack { file_path } => {
("modpack".to_string(), file_path.clone(), Vec::new(), None)
}
DroppedItemType::ShortcutResolved { resolved_to, .. } => {
return candidate_from_result(resolved_to, outer_inner_base);
}
DroppedItemType::Unknown { .. } | DroppedItemType::Multiple { .. } => {
return None;
}
};
Some(DroppedCandidate {
item_type,
file_path,
inner_base: merge_inner_base(outer_inner_base, inner_base),
candidates,
})
}
fn merge_inner_base(
outer: Option<String>,
inner: Option<String>,
) -> Option<String> {
match (outer, inner) {
(Some(outer), Some(inner)) => Some(format!("{outer}/{inner}")),
(Some(outer), None) => Some(outer),
(None, inner) => inner,
}
}
fn push_candidate(
out: &mut Vec<DroppedCandidate>,
candidate: Option<DroppedCandidate>,
) {
let Some(candidate) = candidate else {
return;
};
if candidate.item_type == "unknown"
|| out
.iter()
.any(|existing| same_candidate(existing, &candidate))
|| out.len() >= 8
{
return;
}
out.push(candidate);
}
fn same_candidate(a: &DroppedCandidate, b: &DroppedCandidate) -> bool {
a.item_type == b.item_type
&& a.file_path == b.file_path
&& a.inner_base == b.inner_base
}
fn candidate_priority(candidate: &DroppedCandidate) -> i32 {
let base = match candidate.item_type.as_str() {
"modpack" => 100,
"launcher" | "hmcl_launcher" => 90,
"world_save" => 70,
"data_pack" => 65,
"resource_pack" => 60,
"shader_pack" => 55,
"litematic" => 50,
_ => 0,
};
let depth = candidate
.inner_base
.as_deref()
.map(|base| base.split('/').count() as i32)
.unwrap_or(0);
base - depth * 5
}
fn relative_to(root: &Path, path: &Path) -> Option<String> {
path.strip_prefix(root)
.ok()
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.filter(|rel| !rel.is_empty())
}
fn zip_inner_base(base: &str) -> Option<String> {
(!base.is_empty())
.then(|| base.trim_end_matches('/'))
.and_then(sanitize_inner_base)
}
fn sanitize_inner_base(base: &str) -> Option<String> {
let normalized = base.replace('\\', "/");
let path = std::path::Path::new(&normalized);
if path.is_absolute() {
return None;
}
let mut parts = Vec::new();
for component in path.components() {
match component {
std::path::Component::Normal(part) => {
parts.push(part.to_string_lossy().into_owned());
}
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => return None,
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("/"))
}
}
/// Detect launcher instance markers:
/// - `versions/<id>/<id>.json` pattern (vanilla launcher instance)
/// - a root `.jar` + `.json` pair (modded instance)
/// - `.jar` files in `mods/` (bare instance folder)
fn is_launcher_instance_folder(path: &Path) -> bool {
has_version_json(path)
|| has_direct_version_json(path)
|| (has_root_jar(path) && has_root_json(path))
|| has_mods_jar(path)
}
/// Whether `versions/<id>/<id>.json` exists for any subdirectory of `versions/`.
fn has_version_json(path: &Path) -> bool {
let versions_dir = path.join("versions");
if !versions_dir.is_dir() {
return false;
}
match std::fs::read_dir(&versions_dir) {
Ok(mut dir) => dir.any(|e| {
e.ok().is_some_and(|entry| {
let p = entry.path();
let Some(id) = p.file_name().and_then(|n| n.to_str()) else {
return false;
};
let json_path = p.join(format!("{id}.json"));
let exists = p.is_dir() && json_path.exists();
tracing::debug!(
"classify_folder_content: versions subdir={} json={} exists={}",
id,
json_path.display(),
exists
);
exists
})
}),
Err(e) => {
tracing::debug!(
"classify_folder_content: versions_dir={} read_dir_err={}",
versions_dir.display(),
e
);
false
}
}
}
/// Whether direct child folders of `path` look like version folders
/// (`<id>/<id>.json`). A folder named `versions`, at least two matching
/// children, or a matching child with the same-name `.jar` makes the
/// classification specific enough to avoid false positives.
fn has_direct_version_json(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
};
let mut matching: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let child = entry.path();
if !child.is_dir() {
continue;
}
let Some(id) = child.file_name().and_then(|name| name.to_str()) else {
continue;
};
if child.join(format!("{id}.json")).is_file() {
matching.push(child);
}
}
if matching.is_empty() {
return false;
}
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
return true;
}
if matching.len() >= 2 {
return true;
}
matching.iter().any(|child| {
child
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|id| child.join(format!("{id}.jar")).is_file())
})
}
/// Whether `path` is an Axolotl instance root or a container whose direct
/// child folders are Axolotl instances.
fn is_axolotl_folder(path: &Path) -> bool {
path.join("axolotl_config.json").is_file()
|| has_direct_axolotl_config(path)
}
/// Whether a direct child folder of `path` has `axolotl_config.json`.
fn has_direct_axolotl_config(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
};
entries.flatten().any(|entry| {
let child = entry.path();
child.is_dir() && child.join("axolotl_config.json").is_file()
})
}
/// Whether the directory contains a file with the given extension.
fn dir_has_extension(dir: &Path, extension: &str, label: &str) -> bool {
match std::fs::read_dir(dir) {
Ok(entries) => entries.flatten().any(|entry| {
let p = entry.path();
if !p.is_file() {
return false;
}
let ext = p
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let is_match = ext.eq_ignore_ascii_case(extension);
tracing::debug!(
"classify_folder_content: {label} path={} file={} ext={} is_match={}",
dir.display(),
p.display(),
ext,
is_match
);
is_match
}),
Err(e) => {
tracing::debug!(
"classify_folder_content: {label} path={} read_dir_err={}",
dir.display(),
e
);
false
}
}
}
fn has_root_jar(path: &Path) -> bool {
dir_has_extension(path, "jar", "root_jar_check")
}
fn has_root_json(path: &Path) -> bool {
dir_has_extension(path, "json", "root_json_check")
}
/// Whether `mods/` contains at least one `.jar` file.
fn has_mods_jar(path: &Path) -> bool {
dir_has_extension(&path.join("mods"), "jar", "mods_jar_check")
}
// ─── HMCL data directory discovery ─────────────────────────────────────────
/// Result of looking up a mod file hash on Modrinth.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModrinthLookupResult {
pub hash: String,
pub project_id: String,
pub version_id: String,
pub project_name: Option<String>,
pub project_slug: Option<String>,
pub version_number: Option<String>,
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
}
/// Look up a mod file by SHA1 hash to find matching Modrinth project and version.
///
/// Computes the SHA1 hash of the given file and queries the Modrinth API
/// to find matching versions. Returns project and version information if found.
pub async fn lookup_mod_hash(
path: &Path,
) -> crate::Result<Option<ModrinthLookupResult>> {
let (_, hash) = crate::util::fetch::sha1_file_async(path).await?;
let state = crate::State::get().await?;
let files = crate::state::CachedEntry::get_file_many(
&[&hash],
Some(crate::state::CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await?;
if files.is_empty() {
return Ok(None);
}
let file = &files[0];
let version = crate::state::CachedEntry::get_version(
&ModrinthVersionId::new(file.version_id.clone())?,
Some(crate::state::CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await?;
let project = if let Some(v) = &version {
crate::state::CachedEntry::get_project(
&ModrinthProjectId::new(v.project_id.clone())?,
Some(crate::state::CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await?
} else {
None
};
Ok(Some(ModrinthLookupResult {
hash,
project_id: file.project_id.clone(),
version_id: file.version_id.clone(),
project_name: project.as_ref().map(|p| p.title.clone()),
project_slug: project.as_ref().and_then(|p| p.slug.clone()),
version_number: version.as_ref().map(|v| v.version_number.clone()),
game_versions: version
.as_ref()
.map(|v| v.game_versions.clone())
.unwrap_or_default(),
loaders: version
.as_ref()
.map(|v| v.loaders.clone())
.unwrap_or_default(),
}))
}
// ─── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
/// Minimal valid world `level.dat` payload: NBT root tag `0x0A` with
/// root name `Data` followed by an empty compound.
const VALID_LEVEL_DAT: &[u8] = b"\x0A\x00\x04Data\x00";
/// Minimal valid `pack.mcmeta` with a `pack_format` value.
const VALID_PACK_MCMETA: &[u8] =
br#"{"pack":{"pack_format":15,"description":"test"}}"#;
#[test]
fn test_nonexistent_path() {
let result = classify_dropped_item(Path::new("/nonexistent/path"));
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"nonexistent path should be Unknown"
);
}
#[test]
fn test_regular_mod_jar() {
let dir = tempdir().expect("temp dir");
let jar_path = dir.path().join("testmod.jar");
// Create a minimal ZIP with a fabric.mod.json.
let file = std::fs::File::create(&jar_path).expect("create jar");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"fabric.mod.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"{}").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&jar_path);
assert!(
matches!(result, DroppedItemType::Mod { .. }),
"jar with fabric mod should be classified as Mod: {result:?}"
);
}
#[test]
fn test_litematic_file() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("build.litematic");
std::fs::write(&path, "fake litematic data").expect("write");
let result = classify_dropped_item(&path);
assert!(
matches!(result, DroppedItemType::Litematic { .. }),
"litematic file should be classified as Litematic"
);
}
#[test]
fn test_resource_pack_folder() {
let dir = tempdir().expect("temp dir");
let rp = dir.path().join("my_resource_pack");
std::fs::create_dir(&rp).expect("create dir");
std::fs::write(rp.join("pack.mcmeta"), VALID_PACK_MCMETA)
.expect("write pack.mcmeta");
std::fs::create_dir(rp.join("assets")).expect("create assets dir");
let result = classify_dropped_item(&rp);
assert!(
matches!(result, DroppedItemType::ResourcePack { .. }),
"folder with pack.mcmeta should be ResourcePack"
);
}
#[test]
fn test_world_save() {
let dir = tempdir().expect("temp dir");
let world = dir.path().join("New World");
std::fs::create_dir(&world).expect("create dir");
std::fs::write(world.join("level.dat"), VALID_LEVEL_DAT)
.expect("write level.dat");
let result = classify_dropped_item(&world);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"folder with level.dat should be WorldSave"
);
}
#[test]
fn test_multimc_launcher_folder() {
let dir = tempdir().expect("temp dir");
std::fs::write(dir.path().join("multimc.cfg"), "").expect("write");
let result = classify_dropped_item(dir.path());
assert!(
matches!(result, DroppedItemType::Launcher { launcher_type, .. } if launcher_type == ImportLauncherType::MultiMC),
"folder with multimc.cfg should be MultiMC launcher"
);
}
#[test]
fn test_shader_pack_folder() {
let dir = tempdir().expect("temp dir");
let shaders = dir.path().join("shaders");
std::fs::create_dir(&shaders).expect("create shaders dir");
std::fs::write(shaders.join("composite.fsh"), "shader")
.expect("write shader file");
let result = classify_dropped_item(dir.path());
assert!(
matches!(result, DroppedItemType::ShaderPack { .. }),
"folder with shaders/ should be ShaderPack"
);
}
#[test]
fn test_unknown_file() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("random.xyz");
std::fs::write(&path, "data").expect("write");
let result = classify_dropped_item(&path);
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"unknown extension should be Unknown"
);
}
#[test]
fn test_zip_single_file() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("test.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("test.txt", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"hello").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
// Single .txt file inside ZIP → after extraction, classify_file sees .txt → Unknown.
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"zip with single .txt should resolve to Unknown: {result:?}"
);
}
#[test]
fn test_zip_with_mod_jar() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("modpack.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
// Create a JAR at the archive root.
zip.start_file("testmod.jar", zip::write::FileOptions::<()>::default())
.expect("start entry");
// The extracted .jar has no readable manifest, so classify_jar falls
// back to Mod (the default for JAR files).
zip.write_all(b"fake jar content").expect("write");
zip.finish().expect("finish");
let result = classify_zip_with_extraction(&zip_path);
// Force-analysis extracts the single item and classifies it as a Mod.
assert!(
matches!(result, DroppedItemType::Mod { .. }),
"zip with a single testmod.jar should be classified as Mod: {result:?}"
);
}
#[test]
fn test_zip_nested_world_save() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("world.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
// Entries under a single shared root folder, as produced by zipping
// the world folder itself.
zip.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.start_file(
"My World/region/r.0.0.mca",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"mca").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"zip with a nested level.dat should be classified as WorldSave: {result:?}"
);
}
#[test]
fn test_zip_nested_resource_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("pack.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"My Pack/pack.mcmeta",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_PACK_MCMETA).expect("write");
zip.start_file(
"My Pack/assets/",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::ResourcePack { .. }),
"zip with a nested pack.mcmeta should be classified as ResourcePack: {result:?}"
);
}
#[test]
fn test_zip_modpack_with_nested_level_dat_stays_modpack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("pack.mrpack");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"modrinth.index.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"{\"formatVersion\":1,\"game\":\"minecraft\",\"versionId\":\"1\",\"name\":\"p\",\"files\":[],\"dependencies\":{\"minecraft\":\"1.20.1\"}}")
.expect("write");
// A modpack may ship a world in overrides; the pack signature must
// win over the level.dat marker.
zip.start_file(
"overrides/world/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"fake").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"mrpack with nested level.dat should still be Modpack: {result:?}"
);
}
#[test]
fn test_zip_compressed_minecraft_is_instance_not_resource_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("minecraft.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
// A .minecraft folder embeds resource packs, shader packs and worlds;
// the instance signature must win over those markers.
for name in [
".minecraft/versions/1.20.1/1.20.1.json",
".minecraft/resourcepacks/My Pack/pack.mcmeta",
".minecraft/saves/New World/level.dat",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Generic,
..
}
),
"compressed .minecraft should classify as a Generic instance: {result:?}"
);
}
#[test]
fn test_zip_launcher_folder_with_multiple_instances() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("launcher.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in [
"multimc.cfg",
"instances/Alpha/instance.cfg",
"instances/Beta/instance.cfg",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::MultiMC,
..
}
),
"launcher folder zip should classify as MultiMC: {result:?}"
);
}
#[test]
fn test_zip_wrapping_folder_modpack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("pack.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"My Pack/modrinth.index.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(
b"{\"formatVersion\":1,\"game\":\"minecraft\",\"versionId\":\"1\",\"name\":\"p\",\"files\":[],\"dependencies\":{\"minecraft\":\"1.20.1\"}}",
)
.expect("write");
zip.start_file(
"My Pack/overrides/config/x.txt",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"x").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"modpack inside a wrapping folder should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_deeply_nested_world_save() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("backup.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"backup/worlds/My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.start_file(
"backup/worlds/My World/region/r.0.0.mca",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"x").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"world nested under folders should classify as WorldSave: {result:?}"
);
}
#[test]
fn test_zip_nested_instance_within_limit() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("nested.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"d0/d1/d2/d3/d4/instance.cfg",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"x").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::MultiMC,
..
}
),
"instance at 5 folder levels should classify as MultiMC: {result:?}"
);
}
#[test]
fn test_zip_nesting_depth_limit() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("deep.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"d0/d1/d2/d3/d4/d5/instance.cfg",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"x").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"nesting beyond MAX_ZIP_NESTING_DEPTH should stay Unknown: {result:?}"
);
}
#[test]
fn test_extract_all_rejects_path_traversal() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("evil.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
// A malicious entry that tries to escape the extraction directory.
zip.start_file(
"../../evil.txt",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"pwned").expect("write");
zip.finish().expect("finish");
let out_dir = tempdir().expect("temp dir");
let Ok(mut archive) = zip::ZipArchive::new(
std::fs::File::open(&zip_path).expect("open zip"),
) else {
panic!("zip should open");
};
extract_all(&mut archive, out_dir.path());
assert!(
!out_dir.path().join("evil.txt").exists()
&& !out_dir.path().join("..").join("evil.txt").exists(),
"path traversal entry must not be extracted outside the target dir"
);
}
// ── Golden tests: real launcher / pack packaging habits ──────────────
#[test]
fn test_zip_curseforge_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("cf.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"manifest.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(
br#"{"minecraft":{"version":"1.20.1"},"manifestType":"minecraftModpack","manifestVersion":1,"files":[],"overrides":"overrides"}"#,
)
.expect("write");
zip.start_file("mods/a.jar", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"jar").expect("write");
zip.start_file(
"overrides/config/x.toml",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"x").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"CurseForge pack should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_mcbbs_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("mcbbs.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"manifest.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(br#"{"addons":[{"id":291}]}"#).expect("write");
zip.start_file(
"mcbbs.packmeta",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"{}").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"MCBBS pack should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_mmc_export_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("mmc.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"mmc-pack.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"{}").expect("write");
// instance.cfg would mark a launcher instance; the MMC export
// manifest must win because a pack can carry instance metadata.
zip.start_file(
"instance.cfg",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"MMC export should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_hmcl_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("hmcl.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"modpack.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"{}").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"HMCL pack should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_launcher_bundle() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("bundle.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
// A launcher bundle wraps another pack inside modpack.zip.
zip.start_file("modpack.zip", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"inner pack bytes").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"launcher bundle should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_pcl_single_instance_folder() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("pcl.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in [
"我的整合包/.minecraft/versions/1.20.1/1.20.1.json",
"我的整合包/.minecraft/mods/a.jar",
"我的整合包/launcher_profiles.json",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Generic,
..
}
),
"PCL-style single instance folder should classify as Generic: {result:?}"
);
}
#[test]
fn test_zip_hmcl_portable_instance() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("hmcl.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in [
"HMCL/.hmcl/config/launcher-settings.json",
"HMCL/.minecraft/versions/1.20.1/1.20.1.json",
"HMCL/hmcl.json",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"{}").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::HMCL,
..
}
),
"HMCL portable instance should classify as HMCL: {result:?}"
);
}
#[test]
fn test_zip_nested_world_zips() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("backup.zip");
let mut inner = Vec::new();
{
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(&mut inner));
writer
.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start inner entry");
writer.write_all(VALID_LEVEL_DAT).expect("write inner");
writer.finish().expect("finish inner");
}
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in ["Backup/worlds/world1.zip", "Backup/worlds/world2.zip"] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(&inner).expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"backup of world zips should classify as WorldSave: {result:?}"
);
}
#[test]
fn test_zip_nested_mrpack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("downloads.zip");
let mut inner = Vec::new();
{
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(&mut inner));
writer
.start_file(
"modrinth.index.json",
zip::write::FileOptions::<()>::default(),
)
.expect("start inner entry");
writer
.write_all(
br#"{"formatVersion":1,"files":[],"dependencies":{"minecraft":"1.20.1"}}"#,
)
.expect("write inner");
writer.finish().expect("finish inner");
}
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"Downloads/packs/mypack.mrpack",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(&inner).expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Modpack { .. }),
"nested mrpack should classify as Modpack: {result:?}"
);
}
#[test]
fn test_zip_nested_zip_chain_within_limit() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("chain.zip");
let mut world = Vec::new();
{
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(&mut world));
writer
.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start world entry");
writer.write_all(VALID_LEVEL_DAT).expect("write world");
writer.finish().expect("finish world");
}
let mut c = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut c));
writer
.start_file(
"world.zip",
zip::write::FileOptions::<()>::default(),
)
.expect("start c entry");
writer.write_all(&world).expect("write c");
writer.finish().expect("finish c");
}
let mut b = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut b));
writer
.start_file("c.zip", zip::write::FileOptions::<()>::default())
.expect("start b entry");
writer.write_all(&c).expect("write b");
writer.finish().expect("finish b");
}
let mut a = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut a));
writer
.start_file("b.zip", zip::write::FileOptions::<()>::default())
.expect("start a entry");
writer.write_all(&b).expect("write a");
writer.finish().expect("finish a");
}
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("a.zip", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(&a).expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"4-level nested zip chain should classify as WorldSave: {result:?}"
);
}
#[test]
fn test_zip_mixed_backup_prefers_first_hit() {
// A backup containing an instance, a world and a resource pack is
// classified by the first (alphabetical) hit — instances/ wins.
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("mixed.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in [
"Backup/instances/Alpha/.minecraft/versions/1.20.1/1.20.1.json",
"Backup/worlds/My World/level.dat",
"Backup/resourcepacks/My Pack/pack.mcmeta",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Generic,
..
}
),
"mixed backup should classify as the first hit (Generic instance): {result:?}"
);
}
#[test]
fn test_zip_instance_wins_over_embedded_content() {
// The instance layer must beat every content marker embedded inside
// the game folder.
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("backup.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in [
"我的存档备份/.minecraft/versions/1.20.1/1.20.1.json",
"我的存档备份/.minecraft/resourcepacks/Vanilla Tweaks/pack.mcmeta",
"我的存档备份/.minecraft/saves/New World/level.dat",
"我的存档备份/.minecraft/shaderpacks/My Shader/shaders/a.fsh",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Generic,
..
}
),
"instance must win over embedded content markers: {result:?}"
);
}
// ── Hardening: content confirmation, noise pruning, safety ───────────
#[test]
fn test_zip_backslash_world_is_normalized() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("bs.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"My World\\level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.start_file(
"My World\\region\\r.0.0.mca",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"m").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"backslash world zip should classify as WorldSave: {result:?}"
);
}
#[test]
fn test_zip_invalid_pack_mcmeta_is_still_resource_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("fake.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"Fake Pack/pack.mcmeta",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"not json").expect("write");
zip.start_file(
"Fake Pack/assets/",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::ResourcePack { .. }),
"classification is name-based, so any pack.mcmeta counts: {result:?}"
);
}
#[test]
fn test_zip_empty_shaders_is_not_shader_pack() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("fake.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"Fake Shader/shaders/",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"empty shaders/ must not classify as ShaderPack: {result:?}"
);
}
#[test]
fn test_zip_noise_entries_are_skipped() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("noise.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
for name in [
"__MACOSX/My World/._level.dat",
".git/config",
"Thumbs.db",
".DS_Store",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"noise entries must be skipped during recursion: {result:?}"
);
}
#[test]
fn test_zip_slip_and_absolute_entries_are_ignored() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("evil.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in ["../../evil.txt", "C:/evil.txt", "/evil.txt"] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"e").expect("write");
}
zip.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"unsafe entries must not derail classification: {result:?}"
);
}
#[test]
fn test_zip_empty_archive_is_unknown() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("empty.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
zip::ZipWriter::new(file).finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"empty zip should be Unknown: {result:?}"
);
}
#[test]
fn test_zip_dirs_only_archive_is_unknown() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("dirs.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("dir/", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"").expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::Unknown { .. }),
"directories-only zip should be Unknown: {result:?}"
);
}
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, entry) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
}
*entry = c;
}
let mut crc = 0xFFFF_FFFFu32;
for &byte in data {
crc = table[((crc ^ byte as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
/// Writes a minimal ZIP with one legacy-encrypted (general-purpose bit 0)
/// stored entry plus a plain `level.dat`. The zip crate does not need the
/// `aes-crypto` feature to read names of encrypted entries, which is all
/// classification touches.
fn write_encrypted_entry_zip(path: &Path) {
fn u16(out: &mut Vec<u8>, v: u16) {
out.extend_from_slice(&v.to_le_bytes());
}
fn u32(out: &mut Vec<u8>, v: u32) {
out.extend_from_slice(&v.to_le_bytes());
}
let entries: [(&[u8], &[u8], bool); 2] = [
(b"secret.bin", b"encrypted payload", true),
(b"level.dat", VALID_LEVEL_DAT, false),
];
let mut out = Vec::new();
let mut offsets = Vec::new();
for (name, data, encrypted) in entries {
offsets.push(out.len() as u32);
let flags: u16 = u16::from(encrypted);
let data_size = data.len() as u32 + if encrypted { 12 } else { 0 };
out.extend_from_slice(b"PK\x03\x04");
u16(&mut out, 20);
u16(&mut out, flags);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u32(&mut out, crc32(data));
u32(&mut out, data_size);
u32(&mut out, data.len() as u32);
u16(&mut out, name.len() as u16);
u16(&mut out, 0);
out.extend_from_slice(name);
if encrypted {
out.extend_from_slice(&[0u8; 12]);
}
out.extend_from_slice(data);
}
let cd_start = out.len() as u32;
for (i, (name, data, encrypted)) in entries.into_iter().enumerate() {
let flags: u16 = u16::from(encrypted);
let data_size = data.len() as u32 + if encrypted { 12 } else { 0 };
out.extend_from_slice(b"PK\x01\x02");
u16(&mut out, 20);
u16(&mut out, 20);
u16(&mut out, flags);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u32(&mut out, crc32(data));
u32(&mut out, data_size);
u32(&mut out, data.len() as u32);
u16(&mut out, name.len() as u16);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u32(&mut out, 0);
u32(&mut out, offsets[i]);
out.extend_from_slice(name);
}
let cd_size = out.len() as u32 - cd_start;
out.extend_from_slice(b"PK\x05\x06");
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, entries.len() as u16);
u16(&mut out, entries.len() as u16);
u32(&mut out, cd_size);
u32(&mut out, cd_start);
u16(&mut out, 0);
std::fs::write(path, out).expect("write encrypted zip");
}
#[test]
fn test_zip_encrypted_entry_does_not_break_classification() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("enc.zip");
write_encrypted_entry_zip(&zip_path);
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"encrypted sibling entry must not break world classification: {result:?}"
);
}
#[test]
fn test_disabled_jar_and_zip_files() {
let dir = tempdir().expect("temp dir");
let jar = dir.path().join("mod.jar.disabled");
std::fs::write(&jar, b"not a real jar").expect("write");
let zip_path = dir.path().join("pack.zip.disabled");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("level.dat", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.finish().expect("finish");
let jar_result = classify_dropped_item(&jar);
assert!(
matches!(jar_result, DroppedItemType::Mod { .. }),
"mod.jar.disabled should classify as Mod: {jar_result:?}"
);
let zip_result = classify_dropped_item(&zip_path);
assert!(
matches!(zip_result, DroppedItemType::WorldSave { .. }),
"pack.zip.disabled should classify as WorldSave: {zip_result:?}"
);
}
#[test]
fn test_nested_zip_staging_disk_space_guard() {
let dir = tempdir().expect("temp dir");
// Zero bytes are always available; no filesystem has u64::MAX free.
assert!(temp_dir_has_space(dir.path(), 0));
assert!(!temp_dir_has_space(dir.path(), u64::MAX));
}
#[test]
fn nested_zip_requires_unpack_confirmation_first() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("backup.zip");
let mut inner = Vec::new();
{
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(&mut inner));
writer
.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start inner entry");
writer.write_all(VALID_LEVEL_DAT).expect("write inner");
writer.finish().expect("finish inner");
}
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"Backup/worlds/world1.zip",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(&inner).expect("write");
zip.finish().expect("finish");
let pending = classify_dropped_item_without_nested_unpack(&zip_path);
assert!(
matches!(
&pending,
DroppedItemType::Unknown { reason }
if reason.contains("nested archives")
&& reason.contains("bytes")
),
"first pass must report the nested archive without unpacking: {pending:?}"
);
let full = classify_dropped_item(&zip_path);
assert!(
matches!(full, DroppedItemType::WorldSave { .. }),
"confirmed pass may unpack nested archives: {full:?}"
);
}
#[test]
fn flat_content_does_not_need_nested_unpack_confirmation() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("world.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("level.dat", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.finish().expect("finish");
let result = classify_dropped_item_without_nested_unpack(&zip_path);
assert!(
matches!(result, DroppedItemType::WorldSave { .. }),
"flat archives need no nested-unpack confirmation: {result:?}"
);
}
#[test]
fn test_pack_mcmeta_with_bom_is_valid() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("bom.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("pack.mcmeta", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"\xEF\xBB\xBF{\"pack\":{\"pack_format\":16}}")
.expect("write");
zip.start_file("assets/", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(result, DroppedItemType::ResourcePack { .. }),
"pack.mcmeta with a UTF-8 BOM should classify as ResourcePack: {result:?}"
);
}
/// Writes a minimal ZIP with raw (possibly non-UTF-8) entry names and no
/// UTF-8 flag, as produced by some Chinese packaging tools.
fn write_raw_name_zip(path: &Path, entries: &[(&[u8], &[u8])]) {
fn u16(out: &mut Vec<u8>, v: u16) {
out.extend_from_slice(&v.to_le_bytes());
}
fn u32(out: &mut Vec<u8>, v: u32) {
out.extend_from_slice(&v.to_le_bytes());
}
let mut out = Vec::new();
let mut offsets = Vec::new();
for (name, data) in entries {
offsets.push(out.len() as u32);
out.extend_from_slice(b"PK\x03\x04");
u16(&mut out, 20);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u32(&mut out, crc32(data));
u32(&mut out, data.len() as u32);
u32(&mut out, data.len() as u32);
u16(&mut out, name.len() as u16);
u16(&mut out, 0);
out.extend_from_slice(name);
out.extend_from_slice(data);
}
let cd_start = out.len() as u32;
for (i, (name, data)) in entries.iter().enumerate() {
out.extend_from_slice(b"PK\x01\x02");
u16(&mut out, 20);
u16(&mut out, 20);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u32(&mut out, crc32(data));
u32(&mut out, data.len() as u32);
u32(&mut out, data.len() as u32);
u16(&mut out, name.len() as u16);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, 0);
u32(&mut out, 0);
u32(&mut out, offsets[i]);
out.extend_from_slice(name);
}
let cd_size = out.len() as u32 - cd_start;
out.extend_from_slice(b"PK\x05\x06");
u16(&mut out, 0);
u16(&mut out, 0);
u16(&mut out, entries.len() as u16);
u16(&mut out, entries.len() as u16);
u32(&mut out, cd_size);
u32(&mut out, cd_start);
u16(&mut out, 0);
std::fs::write(path, out).expect("write raw-name zip");
}
#[test]
fn extract_zip_to_dir_handles_non_utf8_names() {
// Chinese tools store names as GB18030 without the UTF-8 flag; the
// zip crate's by_name lookup cannot match them, so extraction must
// read entries by index.
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("gbk.zip");
let (gbk_name, _, _) =
encoding_rs::GB18030.encode("我的世界/level.dat");
write_raw_name_zip(&zip_path, &[(gbk_name.as_ref(), VALID_LEVEL_DAT)]);
let out_dir = tempdir().expect("temp out dir");
extract_zip_to_dir(&zip_path, out_dir.path())
.expect("extract GB18030 zip");
assert!(out_dir.path().join("我的世界/level.dat").exists());
}
#[test]
fn extract_zip_to_dir_normalizes_backslash_names() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("bs.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"My World\\level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.finish().expect("finish");
let out_dir = tempdir().expect("temp out dir");
extract_zip_to_dir(&zip_path, out_dir.path())
.expect("extract backslash zip");
assert!(out_dir.path().join("My World/level.dat").exists());
}
#[test]
fn extract_zip_to_dir_skips_unsafe_and_encrypted_entries() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("mixed.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"../../evil.txt",
zip::write::FileOptions::<()>::default(),
)
.expect("start entry");
zip.write_all(b"e").expect("write");
zip.start_file("level.dat", zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(VALID_LEVEL_DAT).expect("write");
zip.finish().expect("finish");
let out_dir = tempdir().expect("temp out dir");
extract_zip_to_dir(&zip_path, out_dir.path())
.expect("extract mixed zip");
assert!(out_dir.path().join("level.dat").exists());
assert!(!out_dir.path().join("evil.txt").exists());
let enc_dir = tempdir().expect("temp dir");
let enc_path = enc_dir.path().join("enc.zip");
write_encrypted_entry_zip(&enc_path);
let enc_out = tempdir().expect("temp out dir");
extract_zip_to_dir(&enc_path, enc_out.path())
.expect("extract encrypted zip");
assert!(
enc_out.path().join("level.dat").exists(),
"plain entries survive"
);
assert!(
!enc_out.path().join("secret.bin").exists(),
"encrypted entries are skipped"
);
}
#[test]
fn test_zip_pcl_multiple_version_folders_report_common_parent() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("pcl.zip");
let file = std::fs::File::create(&zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for name in [
"test/1.7.10/1.7.10.jar",
"test/1.7.10/1.7.10.json",
"test/1.8.9/1.8.9.jar",
"test/1.8.9/1.8.9.json",
"test/qqwe.txt",
] {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let result = classify_dropped_item(&zip_path);
assert!(
matches!(
result,
DroppedItemType::Launcher {
launcher_type: ImportLauncherType::Generic,
inner_base: Some(ref base),
..
} if base.as_str() == "test"
),
"multiple sibling instance folders should report the common parent: {result:?}"
);
}
#[tokio::test]
async fn generic_scan_enumerates_sibling_instance_folders() {
let dir = tempdir().expect("temp dir");
let base = dir.path().join("test");
std::fs::create_dir_all(base.join("1.7.10")).expect("create");
std::fs::create_dir_all(base.join("1.8.9")).expect("create");
std::fs::write(base.join("1.7.10/1.7.10.jar"), b"j").expect("write");
std::fs::write(base.join("1.7.10/1.7.10.json"), b"{}").expect("write");
std::fs::write(base.join("1.8.9/1.8.9.jar"), b"j").expect("write");
std::fs::write(base.join("1.8.9/1.8.9.json"), b"{}").expect("write");
std::fs::write(base.join("qqwe.txt"), b"x").expect("write");
let instances = crate::api::pack::import::get_importable_instances(
ImportLauncherType::Generic,
base,
)
.await
.expect("scan");
let names: Vec<String> =
instances.iter().map(|i| i.name.clone()).collect();
assert_eq!(instances.len(), 2, "{instances:?}");
assert!(names.contains(&"1.7.10".to_string()), "{names:?}");
assert!(names.contains(&"1.8.9".to_string()), "{names:?}");
}
}