merlon 1.3.1

Mod package manager for the Paper Mario (N64) decompilation
Documentation
//! Distributables are encrypted tarballs with a specific directory structure.
//! They are used to store source code patches and metadata for a mod.
//! 
//! The internal directory structure of a distributable is:
//! 
//!  merlon_v1/
//!  ├── patches/                - Patch files generated by `git format-patch`
//!  │   ├── 0001-Add-foo.patch
//!  │   ├── 0002-Add-bar.patch
//!  │   └── 0003-Add-baz.patch
//!  ├── merlon.toml             - Manifest
//!  ├── LICENSE                 - License
//!  └── README.md               - Documentation
//!

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";

/// A package in the form of a distributable file.
#[derive(Debug)]
#[pyclass(module = "merlon.package.distribute")]
pub struct Distributable {
    path: PathBuf,
}

/// Options for [`Package::export_distributable`].
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.distribute")]
pub struct ExportOptions {
    /// The output path to write the distributable to.
    ///
    /// If not specified, the default is `NAME VERSION.merlon`, where `NAME` is the name of the package
    /// and `VERSION` is the package version as specified in `merlon.toml`.
    #[arg(short, long)]
    #[pyo3(get, set)]
    pub output: Option<PathBuf>,

    /// The base ROM to use as the encryption key.
    ///
    /// If not specified and the package is initialised, `papermario/ver/us/baserom.z64` will be used.
    #[arg(long)]
    #[pyo3(get, set)]
    pub baserom: Option<PathBuf>,
}

/// Options for [`Distributable::apply`].
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.distribute")]
pub struct ApplyOptions {
    /// The base ROM path.
    #[arg(long)]
    #[pyo3(get, set)]
    pub baserom: PathBuf,

    /// Options to build the ROM with.
    #[clap(flatten)]
    #[pyo3(get, set)]
    pub build_rom_options: BuildRomOptions,
}

/// Options for [`Distributable::open_to_dir`].
#[derive(Parser, Debug, Clone)]
#[pyclass(module = "merlon.package.distribute")]
pub struct OpenOptions {
    /// The output directory to write the package source code to.
    /// Must be empty or not exist.
    ///
    /// If not specified, the package name in kebab-case will be used.
    #[arg(short, long)]
    #[pyo3(get, set)]
    pub output: Option<PathBuf>,

    /// The base ROM path.
    #[arg(long)]
    #[pyo3(get, set)]
    pub baserom: PathBuf,
}

#[pymethods]
impl Package {
    /// Exports the package as a distributable `.merlon` file.
    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");

        // TODO: include a binary patch for the baserom so basic users dont have to build from source

        // Copy files into a temporary directory with the correct structure
        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()))?;
            }
        }

        // Compress directory into a tar
        let status = Command::new("tar")
            .arg("--no-xattrs") // Avoid com.apple.provenance
            .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());
        }

        // Encrypt the tar using baserom as hash
        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());
        }

        // Copy encrypted tar to output
        fs::copy(&encrypted_path, &output_path)?;

        Distributable::try_from(output_path)
    }
}

#[pymethods]
impl Distributable {
    /// Opens the distributable into a directory.
    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");

        // Check baserom exists
        if !options.baserom.is_file() {
            bail!("baserom {:?} is not a file", options.baserom);
        }

        // Decrypt tar using baserom as hash
        let status = Command::new("openssl")
            .arg("enc")
            .arg("-d") // decrypt
            .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());
        }

        // Decompress tar into temp dir
        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());
        }

        // Validate structure
        // TODO: move these to Package::try_from
        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());
        }

        // Ensure output directory exists and is empty
        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))?;
        }

        // Copy files into the output directory
        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)
    }

    /// Returns the path to the distributable.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Applies the distributable to a base ROM, and returns the output ROM.
    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,
            })?;

            // Default output path - since we're using a open_scoped tempdir, we need to set output to Some because
            // if it is None the output ROM will be in the tempdir and will be thrown away.
            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)
        })
    }

    /// Opens the distributable into a temporary directory and reads the package manifest. 
    pub fn manifest(&self, baserom: PathBuf) -> Result<Manifest> {
        self.open_scoped(baserom, |package| {
            package.manifest()
        })
    }
}

impl Distributable {
    /// Opens the package into a temporary directory, and calls the given closure with the package.
    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())
    }
}

/// Returns true if the given path is probably a distributable package.
pub fn is_distributable_package(path: &Path) -> bool {
    path.is_file() && path.extension().unwrap_or_default() == EXTENSION
}