use std::path::{Path, PathBuf};
use std::fmt;
use std::fs;
use std::process::{Command, Stdio};
use clap::Parser;
use anyhow::{Result, bail, Context};
use temp_dir::TempDir;
use pyo3::prelude::*;
use crate::package::InitialisedPackage;
use crate::rom::Rom;
use super::init::{InitialiseOptions, BuildRomOptions};
use super::{
Package,
ROOT_DIR_NAME,
MANIFEST_FILE_NAME,
README_FILE_NAME,
LICENSE_FILE_NAME,
PATCHES_DIR_NAME, Manifest,
};
const EXTENSION: &str = "merlon";
#[derive(Debug)]
#[pyclass(module = "merlon.package.distribute")]
pub struct Distributable {
path: PathBuf,
}
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.distribute")]
pub struct ExportOptions {
#[arg(short, long)]
#[pyo3(get, set)]
pub output: Option<PathBuf>,
#[arg(long)]
#[pyo3(get, set)]
pub baserom: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.distribute")]
pub struct ApplyOptions {
#[arg(long)]
#[pyo3(get, set)]
pub baserom: PathBuf,
#[clap(flatten)]
#[pyo3(get, set)]
pub build_rom_options: BuildRomOptions,
}
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.distribute")]
pub struct OpenOptions {
#[arg(short, long)]
#[pyo3(get, set)]
pub output: Option<PathBuf>,
#[arg(long)]
#[pyo3(get, set)]
pub baserom: PathBuf,
}
#[pymethods]
impl Package {
pub fn export_distributable(&self, options: ExportOptions) -> Result<Distributable> {
let baserom_path = match options.baserom {
Some(baserom) => baserom,
None => {
InitialisedPackage::try_from(self.clone())
.context("failed to get baserom from package, try specifying --baserom or running `merlon init`")?
.baserom_path()
},
};
if !baserom_path.is_file() {
bail!("baserom {:?} is not a file", baserom_path);
}
let output_path = match options.output {
Some(output) => output,
None => {
let manifest = self.manifest()?;
let metadata = manifest.metadata();
PathBuf::from(format!("{} {}.merlon", metadata.name(), metadata.version()))
},
};
let tempdir = temp_dir::TempDir::new()?;
let tar_path = tempdir.path().join("package.tar.bz2");
let encrypted_path = tempdir.path().join("package.merlon");
let root_dir = tempdir.path().join(ROOT_DIR_NAME);
fs::create_dir(&root_dir)?;
fs::copy(&self.path.join(MANIFEST_FILE_NAME), &root_dir.join(MANIFEST_FILE_NAME))?;
fs::copy(&self.path.join(README_FILE_NAME), &root_dir.join(README_FILE_NAME))?;
fs::copy(&self.path.join(LICENSE_FILE_NAME), &root_dir.join(LICENSE_FILE_NAME))?;
fs::create_dir(&root_dir.join(PATCHES_DIR_NAME))?;
for entry in fs::read_dir(&self.path.join(PATCHES_DIR_NAME))? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
fs::copy(&path, &root_dir.join(PATCHES_DIR_NAME).join(path.file_name().unwrap()))?;
}
}
let status = Command::new("tar")
.arg("--no-xattrs") .arg("-cjvf")
.arg(&tar_path)
.arg("-C").arg(tempdir.path())
.arg(ROOT_DIR_NAME)
.stderr(Stdio::null())
.status()?;
if !status.success() {
bail!("failed to compress to tar {}", tar_path.display());
}
let status = Command::new("openssl")
.arg("enc")
.arg("-aes-256-cbc")
.arg("-md").arg("sha512")
.arg("-pbkdf2")
.arg("-iter").arg("100000")
.arg("-salt")
.arg("-in").arg(&tar_path)
.arg("-out").arg(&encrypted_path)
.arg("-pass").arg(format!("file:{}", baserom_path.display()))
.status()?;
if !status.success() {
bail!("failed to encrypt tar to {}", encrypted_path.display());
}
fs::copy(&encrypted_path, &output_path)?;
Distributable::try_from(output_path)
}
}
#[pymethods]
impl Distributable {
pub fn open_to_dir(&self, options: OpenOptions) -> Result<Package> {
let temp_dir = TempDir::new()
.context("failed to create temporary directory")?;
let tar_path = temp_dir.path().join("package.tar.bz2");
if !options.baserom.is_file() {
bail!("baserom {:?} is not a file", options.baserom);
}
let status = Command::new("openssl")
.arg("enc")
.arg("-d") .arg("-aes-256-cbc")
.arg("-md").arg("sha512")
.arg("-pbkdf2")
.arg("-iter").arg("100000")
.arg("-salt")
.arg("-in").arg(&self.path)
.arg("-out").arg(&tar_path)
.arg("-pass").arg(format!("file:{}", options.baserom.display()))
.status()
.context("failed run openssl")?;
if !status.success() {
bail!("failed to decrypt {}", self.path.display());
}
let status = Command::new("tar")
.arg("-xjvf")
.arg(&tar_path)
.arg("-C").arg(&temp_dir.path())
.arg(ROOT_DIR_NAME)
.stderr(Stdio::null())
.status()
.context("failed run tar")?;
if !status.success() {
bail!("failed to decompress {}", tar_path.display());
}
let root_dir = temp_dir.path().join(ROOT_DIR_NAME);
if !root_dir.is_dir() {
bail!("{} is missing {}", self.path.display(), ROOT_DIR_NAME);
}
if !root_dir.join(MANIFEST_FILE_NAME).is_file() {
bail!("{} is missing {MANIFEST_FILE_NAME}", self.path.display());
}
if !root_dir.join(README_FILE_NAME).is_file() {
bail!("{} is missing {README_FILE_NAME}", self.path.display());
}
if !root_dir.join(LICENSE_FILE_NAME).is_file() {
bail!("{} is missing {LICENSE_FILE_NAME}", self.path.display());
}
if !root_dir.join(PATCHES_DIR_NAME).is_dir() {
bail!("{} is missing {PATCHES_DIR_NAME}/", self.path.display());
}
let output_dir = match options.output {
Some(output_dir) => output_dir,
None => {
let manifest = Manifest::read_from_path(&root_dir.join(MANIFEST_FILE_NAME))
.context("failed to read manifest")?;
PathBuf::from(manifest.metadata().name().as_kebab_case())
},
};
if output_dir.exists() {
if !output_dir.is_dir() {
bail!("output directory {:?} is not a directory", &output_dir);
}
if fs::read_dir(&output_dir)?.next().is_some() {
bail!("output directory {:?} is not empty", &output_dir);
}
} else {
fs::create_dir(&output_dir)
.with_context(|| format!("failed to create output directory {:?}", &output_dir))?;
}
fs::copy(&root_dir.join(MANIFEST_FILE_NAME), &output_dir.join(MANIFEST_FILE_NAME))
.context("failed to copy manifest")?;
fs::copy(&root_dir.join(README_FILE_NAME), &output_dir.join(README_FILE_NAME))
.context("failed to copy readme")?;
fs::create_dir(&output_dir.join(PATCHES_DIR_NAME))
.context("failed to create patches directory")?;
for entry in fs::read_dir(&root_dir.join(PATCHES_DIR_NAME)).context("failed to read patches directory")? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(file_name) = path.file_name() {
fs::copy(&path, &output_dir.join(PATCHES_DIR_NAME).join(file_name))
.context("failed to copy patch")?;
} else {
log::warn!("patch {:?} has no file name", path);
}
}
}
Package::try_from(output_dir)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn apply(&self, mut options: ApplyOptions) -> Result<Rom> {
self.open_scoped(options.baserom.clone(), |package| {
let initialised = package.to_initialised(InitialiseOptions {
baserom: options.baserom,
rev: None,
})?;
if options.build_rom_options.output.is_none() {
let manifest = initialised.package().manifest()?;
let metadata = manifest.metadata();
let output_path = PathBuf::from(format!("{} {}.z64", metadata.name(), metadata.version()));
options.build_rom_options.output = Some(output_path)
}
initialised.build_rom(options.build_rom_options)
})
}
pub fn manifest(&self, baserom: PathBuf) -> Result<Manifest> {
self.open_scoped(baserom, |package| {
package.manifest()
})
}
}
impl Distributable {
pub fn open_scoped<F, R>(&self, baserom: PathBuf, f: F) -> Result<R>
where
F: FnOnce(Package) -> Result<R>,
{
let temp_dir = TempDir::new()?;
let package = self.open_to_dir(OpenOptions {
output: Some(temp_dir.path().to_owned()),
baserom,
})?;
f(package)
}
}
impl TryFrom<PathBuf> for Distributable {
type Error = anyhow::Error;
fn try_from(path: PathBuf) -> Result<Self> {
if is_distributable_package(&path) {
Ok(Self { path })
} else {
bail!("{} is not a Merlon distributable", path.display());
}
}
}
impl fmt::Display for Distributable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (distributable)", self.path.display())
}
}
pub fn is_distributable_package(path: &Path) -> bool {
path.is_file() && path.extension().unwrap_or_default() == EXTENSION
}