267 lines
8.5 KiB
Rust
267 lines
8.5 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::env;
|
|
use std::error::Error;
|
|
use std::fs;
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use serde::Serialize;
|
|
use wow_mpq::{Archive, special_files::parse_listfile};
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct UnresolvedFile {
|
|
name_hash_a: String,
|
|
name_hash_b: String,
|
|
hash_index: usize,
|
|
block_index: usize,
|
|
size: u64,
|
|
compressed_size: u64,
|
|
flags: String,
|
|
blob: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct UnresolvedManifest {
|
|
version: u32,
|
|
archive_blocks: usize,
|
|
resolved_blocks: usize,
|
|
special_blocks: usize,
|
|
unresolved: Vec<UnresolvedFile>,
|
|
}
|
|
|
|
fn guess_extension(data: &[u8]) -> &'static str {
|
|
match data {
|
|
[b'B', b'L', b'P', b'1' | b'2', ..] => "blp",
|
|
[b'M', b'D', b'2', b'0' | b'1', ..] => "m2",
|
|
[b'S', b'K', b'I', b'N', ..] => "skin",
|
|
[b'W', b'D', b'B', b'C', ..] => "dbc",
|
|
[b'O', b'g', b'g', b'S', ..] => "ogg",
|
|
[b'R', b'I', b'F', b'F', ..] => "wav",
|
|
[0x89, b'P', b'N', b'G', ..] => "png",
|
|
[b'D', b'D', b'S', b' ', ..] => "dds",
|
|
[0xff, 0xd8, 0xff, ..] => "jpg",
|
|
_ => "bin",
|
|
}
|
|
}
|
|
|
|
fn safe_output_path(root: &Path, archive_path: &str) -> Result<PathBuf, Box<dyn Error>> {
|
|
let normalized = archive_path.replace('\\', "/");
|
|
let mut relative = PathBuf::new();
|
|
|
|
for part in normalized.split('/') {
|
|
if part.is_empty() || part == "." {
|
|
continue;
|
|
}
|
|
if part == ".." || part.contains(':') {
|
|
return Err(format!("unsafe archive path: {archive_path}").into());
|
|
}
|
|
relative.push(part);
|
|
}
|
|
|
|
if relative.as_os_str().is_empty()
|
|
|| relative
|
|
.components()
|
|
.any(|component| !matches!(component, Component::Normal(_)))
|
|
{
|
|
return Err(format!("unsafe archive path: {archive_path}").into());
|
|
}
|
|
|
|
Ok(root.join(relative))
|
|
}
|
|
|
|
fn extract_archive(
|
|
archive_path: &Path,
|
|
output_dir: &Path,
|
|
external_listfiles: &[String],
|
|
) -> Result<(), Box<dyn Error>> {
|
|
if output_dir.exists() && fs::read_dir(output_dir)?.next().is_some() {
|
|
return Err(format!(
|
|
"output directory is not empty: {} (extract to a new directory)",
|
|
output_dir.display()
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut archive = Archive::open(archive_path)?;
|
|
let all_entries = archive.list_all()?;
|
|
let all_blocks: HashSet<usize> = all_entries
|
|
.iter()
|
|
.filter_map(|entry| entry.table_indices.and_then(|(_, block)| block))
|
|
.collect();
|
|
let hashes_by_block: HashMap<usize, (u32, u32)> = archive
|
|
.list_all_with_hashes()?
|
|
.into_iter()
|
|
.filter_map(|entry| Some((entry.table_indices?.1?, entry.hashes?)))
|
|
.collect();
|
|
let mut special_blocks = HashSet::new();
|
|
for special in [
|
|
"(listfile)",
|
|
"(attributes)",
|
|
"(signature)",
|
|
"(patch_metadata)",
|
|
] {
|
|
if let Some(info) = archive.find_file(special)? {
|
|
special_blocks.insert(info.block_index);
|
|
}
|
|
}
|
|
|
|
let mut files = Vec::new();
|
|
if let Ok(listfile) = archive.read_file("(listfile)") {
|
|
files.extend(parse_listfile(&listfile)?);
|
|
}
|
|
for listfile_path in external_listfiles {
|
|
files.extend(parse_listfile(&fs::read(listfile_path)?)?);
|
|
}
|
|
if files.is_empty() {
|
|
return Err(format!(
|
|
"{} has no readable (listfile); provide an external listfile",
|
|
archive_path.display()
|
|
)
|
|
.into());
|
|
}
|
|
|
|
fs::create_dir_all(output_dir)?;
|
|
let mut seen = HashSet::new();
|
|
let mut matched_blocks = HashSet::new();
|
|
let mut extracted = 0usize;
|
|
|
|
for archive_name in files {
|
|
if archive_name.starts_with('(') && archive_name.ends_with(')') {
|
|
continue;
|
|
}
|
|
|
|
// MPQ file lookup is case-insensitive. Avoid writing the same logical path
|
|
// twice on Windows when a malformed listfile contains casing duplicates.
|
|
if !seen.insert(archive_name.to_ascii_lowercase()) {
|
|
continue;
|
|
}
|
|
|
|
let Some(info) = archive.find_file(&archive_name)? else {
|
|
continue;
|
|
};
|
|
if !matched_blocks.insert(info.block_index) {
|
|
continue;
|
|
}
|
|
|
|
let output_path = safe_output_path(output_dir, &archive_name)?;
|
|
if let Some(parent) = output_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let data = archive
|
|
.read_file(&archive_name)
|
|
.map_err(|error| format!("cannot extract {archive_name}: {error}"))?;
|
|
fs::write(&output_path, data)?;
|
|
extracted += 1;
|
|
|
|
if extracted.is_multiple_of(1000) {
|
|
println!("Extracted {extracted} files...");
|
|
}
|
|
}
|
|
|
|
println!(
|
|
"Extracted {extracted} files from {} to {} ({} data blocks, {} special blocks)",
|
|
archive_path.display(),
|
|
output_dir.display(),
|
|
all_blocks.len(),
|
|
special_blocks.len()
|
|
);
|
|
|
|
let unresolved_blocks: Vec<usize> = all_blocks
|
|
.difference(&special_blocks)
|
|
.filter(|block| !matched_blocks.contains(block))
|
|
.copied()
|
|
.collect();
|
|
if !unresolved_blocks.is_empty() {
|
|
let metadata_root = output_dir.join("__mpqmeta__");
|
|
let blob_root = metadata_root.join("unresolved");
|
|
fs::create_dir_all(&blob_root)?;
|
|
let mut unresolved = Vec::with_capacity(unresolved_blocks.len());
|
|
|
|
for block_index in unresolved_blocks {
|
|
let entry = all_entries
|
|
.iter()
|
|
.find(|entry| entry.table_indices.and_then(|(_, block)| block) == Some(block_index))
|
|
.ok_or_else(|| format!("missing metadata for block {block_index}"))?;
|
|
let (hash_index, _) = entry
|
|
.table_indices
|
|
.ok_or_else(|| format!("missing table indices for block {block_index}"))?;
|
|
if entry.is_encrypted() || entry.is_patch_file() {
|
|
return Err(format!(
|
|
"unresolved block {block_index} is encrypted or is a binary patch"
|
|
)
|
|
.into());
|
|
}
|
|
let (name_hash_a, name_hash_b) = hashes_by_block
|
|
.get(&block_index)
|
|
.copied()
|
|
.ok_or_else(|| format!("missing name hashes for block {block_index}"))?;
|
|
let data = archive.read_file_by_indices(hash_index, Some(block_index))?;
|
|
let extension = guess_extension(&data);
|
|
let blob_name =
|
|
format!("{name_hash_a:08x}-{name_hash_b:08x}-{block_index:08}.{extension}");
|
|
fs::write(blob_root.join(&blob_name), data)?;
|
|
unresolved.push(UnresolvedFile {
|
|
name_hash_a: format!("{name_hash_a:08x}"),
|
|
name_hash_b: format!("{name_hash_b:08x}"),
|
|
hash_index,
|
|
block_index,
|
|
size: entry.size,
|
|
compressed_size: entry.compressed_size,
|
|
flags: format!("0x{:08x}", entry.flags),
|
|
blob: format!("unresolved/{blob_name}"),
|
|
});
|
|
}
|
|
|
|
unresolved.sort_by_key(|entry| entry.block_index);
|
|
let manifest = UnresolvedManifest {
|
|
version: 1,
|
|
archive_blocks: all_blocks.len(),
|
|
resolved_blocks: matched_blocks.len(),
|
|
special_blocks: special_blocks.len(),
|
|
unresolved,
|
|
};
|
|
fs::write(
|
|
metadata_root.join("unresolved.json"),
|
|
serde_json::to_vec_pretty(&manifest)?,
|
|
)?;
|
|
println!(
|
|
"Stored {} path-unresolved data blocks in {}",
|
|
manifest.unresolved.len(),
|
|
metadata_root.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn Error>> {
|
|
let args: Vec<String> = env::args().collect();
|
|
if args.len() < 3 {
|
|
eprintln!("Usage: mpqextract <archive.mpq> <output_dir> [external-listfile ...]");
|
|
std::process::exit(2);
|
|
}
|
|
|
|
extract_archive(Path::new(&args[1]), Path::new(&args[2]), &args[3..])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn keeps_safe_relative_paths() {
|
|
let root = Path::new("output");
|
|
assert_eq!(
|
|
safe_output_path(root, "Character\\Human\\Human.m2").unwrap(),
|
|
root.join("Character").join("Human").join("Human.m2")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_parent_and_drive_paths() {
|
|
assert!(safe_output_path(Path::new("output"), "..\\secret").is_err());
|
|
assert!(safe_output_path(Path::new("output"), "C:\\secret").is_err());
|
|
}
|
|
}
|