use std::collections::HashSet;
use std::io::Error as IoError;
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use std::{env, fs};
use utils::*;
use rayon::prelude::ParallelIterator;
use rayon::str::ParallelString;
pub(crate) mod utils {
use std::ffi::OsStr;
use std::io::{Error as IoError, ErrorKind as IoErrorKind, Result as IoResult};
use std::path::Path;
pub trait PathExt {
fn file_exists(&self) -> IoResult<()>;
fn dir_exists(&self) -> IoResult<()>;
fn vpk_exists(&self) -> IoResult<()>;
fn has_ext_res(&self, ext: impl AsRef<OsStr>) -> IoResult<()>;
fn has_ext(&self, ext: impl AsRef<OsStr>) -> bool {
self.has_ext_res(ext).is_ok()
}
fn is_proper_vpk_ish(&self) -> IoResult<()>;
fn contains(&self, str: &str) -> bool;
}
impl PathExt for Path {
fn file_exists(&self) -> IoResult<()> {
if !self.is_file() {
Err(io_error_other(format!("`{}` isn't a file or doesn't exist", self.display())))
} else {
Ok(())
}
}
fn dir_exists(&self) -> IoResult<()> {
if !self.is_dir() {
Err(io_error_other(format!("`{}` is not a directory", self.display())))
} else {
Ok(())
}
}
fn vpk_exists(&self) -> IoResult<()> {
self.has_ext_res("vpk")?;
self.file_exists()?;
Ok(())
}
fn has_ext_res(&self, ext: impl AsRef<OsStr>) -> IoResult<()> {
fn has_ext(path: &Path, ext: &OsStr) -> IoResult<()> {
if path.extension() != Some(ext) {
Err(io_error_other("invalid extension"))
} else {
Ok(())
}
}
has_ext(self, ext.as_ref())
}
fn is_proper_vpk_ish(&self) -> IoResult<()> {
fn is_bad(path: &Path) -> Option<()> {
let stem = path.file_stem()?.to_str()?;
let nums = stem.rsplit_once('_')?.1;
if nums.len() != 3 {
return None;
}
if nums.bytes().all(|b| b.is_ascii_digit()) {
return Some(());
}
None
}
self.has_ext("vpk");
if is_bad(self).is_some() {
Err(io_error_other(format!(
"`{}` is a sub-part of a multi-part .vpk file",
self.display()
)))
} else {
Ok(())
}
}
fn contains(&self, str: &str) -> bool {
if let Some(path) = self.to_str() {
path.contains(str)
} else {
false
}
}
}
pub fn io_error_other<E>(error: E) -> IoError
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
IoError::new(IoErrorKind::Other, error.into())
}
pub fn string_from_utf8_lossy(bytes: Vec<u8>) -> String {
let str = match String::from_utf8_lossy(&bytes) {
std::borrow::Cow::Borrowed(s) => s,
std::borrow::Cow::Owned(s) => return s,
};
debug_assert!(
str.as_bytes() == bytes,
"String::from_utf8_lossy changed the bytes somehow O_O"
);
unsafe { String::from_utf8_unchecked(bytes) }
}
}
pub type VpkResult<T> = std::result::Result<T, VpkError>;
#[derive(Debug)]
pub enum VpkError {
Src(IoError),
Dest(IoError),
MiscIo(IoError),
CmdPath(IoError),
CmdExec(IoError),
CmdFailed(ExitStatus),
}
impl From<IoError> for VpkError {
fn from(error: IoError) -> Self {
VpkError::MiscIo(error)
}
}
impl std::fmt::Display for VpkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VpkError::Src(err) => write!(f, "Source dir/vpk error: {err}"),
VpkError::Dest(err) => write!(f, "Destination dir/vpk error: {err}"),
VpkError::MiscIo(err) => write!(f, "Misc io error: {err}"),
VpkError::CmdPath(err) => write!(f, "Command path error: {err}"),
VpkError::CmdExec(err) => write!(f, "Command exec error: {err}"),
VpkError::CmdFailed(err) => write!(f, "Command failed: {err}"),
}
}
}
impl std::error::Error for VpkError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Options {
pub threads: u32,
pub verbose: bool,
pub show_progress: bool,
}
impl Default for Options {
fn default() -> Self {
Self { threads: 1, verbose: Default::default(), show_progress: Default::default() }
}
}
pub fn vpk_cmd(vpk: impl AsRef<Path>) -> VpkResult<Command> {
fn vpk_cmd(vpk: &Path) -> VpkResult<Command> {
vpk.file_exists().map_err(VpkError::CmdPath)?;
let vpk = env::current_dir()?.join(vpk);
let parent = vpk.parent().ok_or_else(|| {
VpkError::CmdPath(io_error_other(format!(
"`{}` has no parent directory",
vpk.display()
)))
})?;
parent.dir_exists().map_err(VpkError::CmdPath)?;
let mut cmd = Command::new(&vpk);
cmd.env("LD_LIBRARY_PATH", parent);
Ok(cmd)
}
vpk_cmd(vpk.as_ref())
}
pub fn list(cmd_path: impl AsRef<Path>, vpk: impl AsRef<Path>) -> VpkResult<String> {
fn list(path: &Path, vpk: &Path) -> VpkResult<String> {
vpk.vpk_exists().map_err(VpkError::Src)?;
let output =
vpk_cmd(path)?.args(["l".as_ref(), vpk]).output().map_err(VpkError::CmdExec)?;
if !output.status.success() {
return Err(VpkError::CmdFailed(output.status));
}
Ok(string_from_utf8_lossy(output.stdout))
}
list(cmd_path.as_ref(), vpk.as_ref())
}
pub fn extract_all(
cmd_path: impl AsRef<Path>, src_vpk: impl AsRef<Path>, dest_dir: impl AsRef<Path>,
options: Options,
) -> VpkResult<()> {
fn extract_all(
cmd_path: &Path, src_vpk: &Path, dest_dir: &Path, options: Options,
) -> VpkResult<()> {
let files = list(cmd_path, src_vpk)?;
let parents: HashSet<&Path> = files
.lines()
.flat_map(|file| {
Path::new(file)
.ancestors()
.skip(1)
.filter(|parent| !parent.as_os_str().is_empty())
})
.collect();
for dir in parents {
if options.verbose {
eprintln!("creating dir `{}`", dir.display());
}
fs::create_dir_all(dest_dir.join(dir))?;
}
fs::create_dir_all(dest_dir)?;
let src_vpk = src_vpk.canonicalize().map_err(VpkError::Src)?;
files
.par_lines()
.map(|file| extract_one(cmd_path, &src_vpk, dest_dir, file))
.collect()
}
extract_all(cmd_path.as_ref(), src_vpk.as_ref(), dest_dir.as_ref(), options)
}
fn extract_one(
cmd_path: impl AsRef<Path>, src_vpk: impl AsRef<Path>, dest_dir: impl AsRef<Path>,
entry: impl AsRef<Path>,
) -> VpkResult<()> {
fn extract_one(
cmd_path: &Path, src_vpk: &Path, dest_dir: &Path, entry: &Path,
) -> VpkResult<()> {
let status = vpk_cmd(cmd_path)?
.args(["x".as_ref(), src_vpk, entry])
.current_dir(dest_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(VpkError::CmdExec)?;
if !status.success() {
return Err(VpkError::CmdFailed(status));
}
Ok(())
}
extract_one(cmd_path.as_ref(), src_vpk.as_ref(), dest_dir.as_ref(), entry.as_ref())
}
pub fn archive(
cmd_path: impl AsRef<Path>, src_dir: impl AsRef<Path>, dest_vpk: Option<impl AsRef<Path>>,
) -> VpkResult<()> {
fn archive(cmd_path: &Path, src_dir: &Path, dest_vpk: Option<&Path>) -> VpkResult<()> {
if let Some(dest_vpk) = dest_vpk {
dest_vpk.has_ext_res("vpk").map_err(VpkError::Dest)?;
let dest_vpk = env::current_dir()?.join(dest_vpk);
let dest_dir = dest_vpk
.parent()
.ok_or_else(|| {
io_error_other(format!("`{}` has no parent dir", dest_vpk.display()))
})
.map_err(VpkError::Dest)?;
fs::create_dir_all(dest_dir)?;
}
src_dir.dir_exists().map_err(VpkError::Src)?;
let src_dir = src_dir.canonicalize().map_err(VpkError::Src)?;
let mut cmd = vpk_cmd(cmd_path).unwrap();
let status = cmd
.args([&src_dir])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(VpkError::CmdExec)?;
if !status.success() {
return Err(VpkError::CmdFailed(status));
}
if let Some(dest_vpk) = dest_vpk {
let mut out_vpk = src_dir;
out_vpk.set_extension("vpk");
fs::rename(out_vpk, dest_vpk)?;
}
Ok(())
}
let dest_vpk = dest_vpk.as_ref().map(|v| v.as_ref());
archive(cmd_path.as_ref(), src_dir.as_ref(), dest_vpk)
}
#[cfg(test)]
mod tests {
#[cfg(not(unix))]
compile_error!("can only be tested on Linux/Unix right now");
use std::path::PathBuf;
use lazy_static::lazy_static;
use super::*;
const TRUTH_DIR: &str = "test/truth";
const TRUTH_VPK: &str = "test/truth.vpk";
lazy_static! {
static ref HOME: PathBuf = std::env::var("HOME").unwrap().into();
static ref TF2: PathBuf = HOME.join(".local/share/Steam/steamapps/common/Team Fortress 2");
static ref TF2_TF: PathBuf = TF2.join("tf");
static ref VPK_CMD: PathBuf = TF2.join("bin/vpk_linux32");
static ref TEST_DIR: &'static Path = Path::new("test");
}
impl AsRef<Path> for VPK_CMD {
fn as_ref(&self) -> &Path {
self.as_path()
}
}
fn assert_env() {
assert!(TF2.is_dir());
assert!(TF2_TF.is_dir());
assert!(VPK_CMD.is_file());
let cwd = std::env::current_dir().unwrap();
let cwd_test = cwd.join("test");
assert_eq!(
TEST_DIR.canonicalize().unwrap(),
cwd_test.canonicalize().unwrap(),
"bad current working directory"
);
assert!(TEST_DIR.is_dir());
assert!(cwd_test.is_dir());
assert!(Path::new(TRUTH_DIR).is_dir());
assert!(Path::new(TRUTH_VPK).is_file());
}
fn filename(path: impl AsRef<Path>) -> String {
path.as_ref().file_name().unwrap().to_string_lossy().into_owned()
}
#[test]
fn list_test_tf2() {
assert_env();
eprintln!("TODO: horrible test as tf2 can just update");
let tf2_vpk = TF2_TF.join("tf2_sound_misc_dir.vpk");
let list = list(&VPK_CMD, tf2_vpk).unwrap();
assert_eq!(3227, list.lines().count());
}
#[test]
fn list_test() {
assert_env();
let list = list(&VPK_CMD, TRUTH_VPK).unwrap();
let truth = "file1.txt\n\
file2.txt\n\
folder1/folder1_file1.txt\n\
folder1/folder2/folder2_file2.txt\n\
hello_world/hello_world.txt\n";
assert_eq!(truth, list);
}
#[test]
fn extract_test() {
assert_env();
let test_dir = &TEST_DIR.join("test_extract");
let dest_dir = &test_dir.join("dest");
_ = fs::remove_dir_all(test_dir);
eprintln!("extracting...\t{} -> {}", filename(TRUTH_VPK), filename(dest_dir));
extract_all(&VPK_CMD, TRUTH_VPK, dest_dir, Options::default()).unwrap();
eprintln!("checking contents");
assert_eq!("file1", fs::read_to_string(dest_dir.join("file1.txt")).unwrap());
assert_eq!("file2", fs::read_to_string(dest_dir.join("file2.txt")).unwrap());
assert_eq!(
"folder1_file1",
fs::read_to_string(dest_dir.join("folder1/folder1_file1.txt")).unwrap()
);
assert_eq!(
"folder2_file2",
fs::read_to_string(dest_dir.join("folder1/folder2/folder2_file2.txt")).unwrap()
);
assert_eq!(
"Hello World!",
fs::read_to_string(dest_dir.join("hello_world/hello_world.txt")).unwrap()
);
_ = fs::remove_dir_all(test_dir);
}
#[test]
fn archive_test() {
assert_env();
let test_dir = &TEST_DIR.join("test_archive");
let dest_dir = &test_dir.join("dest");
let dest_vpk = &dest_dir.with_extension("vpk");
let src_dir = &test_dir.join("src");
let src_vpk = &src_dir.with_extension("vpk");
_ = fs::remove_dir_all(test_dir);
eprintln!("copying... \t{} -> {}", filename(TRUTH_VPK), filename(src_vpk));
let src_vpk_path: &Path = src_vpk.as_ref();
assert!(!src_vpk_path.exists());
_ = fs::create_dir_all(src_vpk_path.parent().unwrap());
fs::copy(TRUTH_VPK, src_vpk).unwrap_or_else(|_| {
panic!(
"error copying `{TRUTH_VPK}` to `{src_vpk:?}` for this test. \
they might exist already"
)
});
eprintln!("extracting...\t{} -> {}", filename(src_vpk), filename(src_dir));
extract_all(&VPK_CMD, src_vpk, src_dir, Options::default()).unwrap();
_ = fs::remove_file(src_vpk);
eprintln!("archiving...\t{} -> {}", filename(src_dir), filename(dest_vpk));
archive(&VPK_CMD, src_dir, Some(dest_vpk)).unwrap();
eprintln!("Setup done");
eprintln!("extracting...\t{} -> {}", filename(dest_vpk), filename(dest_dir));
extract_all(&VPK_CMD, dest_vpk, dest_dir, Options::default()).unwrap();
eprintln!("testing {}...", filename(dest_dir));
assert_eq!("file1", fs::read_to_string(dest_dir.join("file1.txt")).unwrap());
assert_eq!("file2", fs::read_to_string(dest_dir.join("file2.txt")).unwrap());
assert_eq!(
"folder1_file1",
fs::read_to_string(dest_dir.join("folder1/folder1_file1.txt")).unwrap()
);
assert_eq!(
"folder2_file2",
fs::read_to_string(dest_dir.join("folder1/folder2/folder2_file2.txt")).unwrap()
);
assert_eq!(
"Hello World!",
fs::read_to_string(dest_dir.join("hello_world/hello_world.txt")).unwrap()
);
_ = fs::remove_dir_all(test_dir);
}
}