#![deny(missing_docs)]
use crate::client::ThunderstoreClient;
use crate::error::{Error, Result};
use crate::profile::modlist::{ProfileMod, SemVer};
use crate::util::{canonicalize_missing, same_file};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path, PathBuf};
use zip::write::{FileOptions, ZipWriter};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExportMod {
pub name: String,
pub version: SemVer,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ExportFormat {
pub profile_name: String,
pub mods: Vec<ExportMod>,
}
pub fn export_format_from_mods(profile_name: &str, mods: &[ProfileMod]) -> ExportFormat {
let mods = mods
.iter()
.map(|entry| ExportMod {
name: entry.name.clone(),
version: entry.version_number,
enabled: entry.enabled,
})
.collect();
ExportFormat {
profile_name: profile_name.to_string(),
mods,
}
}
pub fn export_r2x_to_string(export: &ExportFormat) -> Result<String> {
serde_yaml_ng::to_string(export)
.map_err(|e| Error::Profile(format!("serializing export.r2x: {e}")))
}
pub fn parse_export_r2x(yaml: &str) -> Result<ExportFormat> {
serde_yaml_ng::from_str(yaml).map_err(|e| Error::Profile(format!("parsing export.r2x: {e}")))
}
const CONFIG_EXTENSIONS: [&str; 6] = [".cfg", ".txt", ".json", ".yml", ".yaml", ".ini"];
fn is_pruned(rel: &Path) -> bool {
let normalized = rel.to_string_lossy().replace('\\', "/");
let prefixes = [
"dotnet/",
"_state/",
"MelonLoader/",
"BepInEx/config/",
"GDWeave/core/",
"GDWeave/mods/",
];
if prefixes.iter().any(|p| normalized.starts_with(p)) {
return true;
}
if normalized == "mods.yml" || normalized == "GDWeave/GDWeave.log" {
return true;
}
is_plugin_manifest(&normalized)
}
fn is_plugin_manifest(normalized: &str) -> bool {
let parts: Vec<&str> = normalized.split('/').collect();
parts.len() == 4
&& parts[0] == "BepInEx"
&& parts[1] == "plugins"
&& parts[3].eq_ignore_ascii_case("manifest.json")
}
fn is_config_file(rel: &Path) -> bool {
let name = match rel.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_ascii_lowercase(),
None => return false,
};
CONFIG_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
}
fn collect_export_paths(target_dir: &Path) -> Result<Vec<PathBuf>> {
let mut found = Vec::new();
collect_files(target_dir, target_dir, &mut found)?;
let mut kept: Vec<PathBuf> = found
.into_iter()
.filter(|rel| is_config_file(rel) && !is_pruned(rel))
.collect();
kept.sort();
Ok(kept)
}
fn collect_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in
fs::read_dir(dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
{
let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
collect_files(root, &path, out)?;
} else if let Ok(rel) = path.strip_prefix(root) {
out.push(rel.to_path_buf());
}
}
Ok(())
}
const EXPORT_MANIFEST: &str = "export.r2x";
const ARCHIVE_CONFIG_DIR: &str = "config";
const BEPINEX_CONFIG_DIR: &str = "BepInEx/config";
pub fn build_r2z_in(target_dir: &Path, profile_name: &str) -> Result<Vec<u8>> {
let mods = crate::profile::modlist::read(target_dir)?;
let manifest = export_r2x_to_string(&export_format_from_mods(profile_name, &mods))?;
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip
.start_file(EXPORT_MANIFEST, options)
.map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
zip
.write_all(manifest.as_bytes())
.map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
let config_root = target_dir.join(BEPINEX_CONFIG_DIR);
let mut shared = Vec::new();
collect_files(&config_root, &config_root, &mut shared)?;
shared.sort();
for rel in shared {
let archive_path = format!(
"{ARCHIVE_CONFIG_DIR}/{}",
rel.to_string_lossy().replace('\\', "/")
);
add_zip_file(&mut zip, options, &archive_path, &config_root.join(&rel))?;
}
for rel in collect_export_paths(target_dir)? {
let archive_path = rel.to_string_lossy().replace('\\', "/");
add_zip_file(&mut zip, options, &archive_path, &target_dir.join(&rel))?;
}
let cursor = zip
.finish()
.map_err(|e| Error::Profile(format!("finishing export archive: {e}")))?;
Ok(cursor.into_inner())
}
fn add_zip_file(
zip: &mut ZipWriter<Cursor<Vec<u8>>>,
options: FileOptions<'_, ()>,
archive_path: &str,
source: &Path,
) -> Result<()> {
let bytes =
fs::read(source).map_err(|e| Error::Profile(format!("reading {}: {}", source.display(), e)))?;
zip
.start_file(archive_path, options)
.map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
zip
.write_all(&bytes)
.map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
Ok(())
}
pub fn export_to_file_in(
target_dir: &Path,
profile_name: &str,
out_dir: &Path,
timestamp_millis: u64,
) -> Result<PathBuf> {
crate::profile::layout::validate_name(profile_name)?;
let bytes = build_r2z_in(target_dir, profile_name)?;
fs::create_dir_all(out_dir)
.map_err(|e| Error::Profile(format!("creating {}: {}", out_dir.display(), e)))?;
let path = out_dir.join(format!("{profile_name}_{timestamp_millis}.r2z"));
fs::write(&path, bytes)
.map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))?;
Ok(path)
}
pub fn export_to_file(
base: &Path,
game: &str,
profile_name: &str,
timestamp_millis: u64,
) -> Result<PathBuf> {
export_to_file_in(
&crate::profile::layout::profile_dir(base, game, profile_name),
profile_name,
&base.join(game).join("exports"),
timestamp_millis,
)
}
const MAX_EXPORT_CODE_BYTES: usize = 20_000_000;
const PROFILE_DATA_PREFIX: &str = "#r2modman";
fn encode_profile_payload(zip_bytes: &[u8]) -> Result<Vec<u8>> {
if zip_bytes.len() > MAX_EXPORT_CODE_BYTES {
return Err(Error::SizeLimitExceeded {
context: "profile export".to_string(),
limit: MAX_EXPORT_CODE_BYTES as u64,
actual: Some(zip_bytes.len() as u64),
});
}
let mut payload = format!("{PROFILE_DATA_PREFIX}\n").into_bytes();
payload.extend_from_slice(STANDARD.encode(zip_bytes).as_bytes());
Ok(payload)
}
pub async fn export_code_in(
target_dir: &Path,
profile_name: &str,
client: &ThunderstoreClient,
) -> Result<String> {
let payload = encode_profile_payload(&build_r2z_in(target_dir, profile_name)?)?;
client.create_profile_code(&payload).await
}
pub async fn export_code(
base: &Path,
game: &str,
profile_name: &str,
client: &ThunderstoreClient,
) -> Result<String> {
export_code_in(
&crate::profile::layout::profile_dir(base, game, profile_name),
profile_name,
client,
)
.await
}
const BLOCKED_IMPORT_EXTENSIONS: [&str; 12] = [
".dll", ".exe", ".bat", ".cmd", ".com", ".ps1", ".sh", ".js", ".vbs", ".msi", ".scr", ".jar",
];
fn decode_profile_payload(body: &[u8]) -> Result<Vec<u8>> {
let text = std::str::from_utf8(body)
.map_err(|e| Error::Profile(format!("profile code is not valid UTF-8: {e}")))?;
let encoded = text
.strip_prefix(PROFILE_DATA_PREFIX)
.ok_or_else(|| Error::Profile("profile code is missing its #r2modman prefix".to_string()))?;
STANDARD
.decode(encoded.trim())
.map_err(|e| Error::Profile(format!("decoding profile code: {e}")))
}
const MAX_IMPORT_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
const MAX_IMPORT_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
fn read_capped_entry(entry: &mut impl Read, label: &str) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
entry
.take(MAX_IMPORT_ENTRY_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| Error::Profile(format!("reading {label}: {e}")))?;
if bytes.len() as u64 > MAX_IMPORT_ENTRY_BYTES {
return Err(Error::ExtractLimitExceeded(format!(
"{label} exceeds the {MAX_IMPORT_ENTRY_BYTES}-byte per-entry import cap"
)));
}
Ok(bytes)
}
pub fn read_export(bytes: &[u8]) -> Result<ExportFormat> {
match zip::ZipArchive::new(Cursor::new(bytes.to_vec())) {
Ok(mut archive) => {
let mut entry = archive
.by_name(EXPORT_MANIFEST)
.map_err(|e| Error::Profile(format!("archive has no {EXPORT_MANIFEST}: {e}")))?;
let manifest_bytes = read_capped_entry(&mut entry, EXPORT_MANIFEST)?;
let manifest = std::str::from_utf8(&manifest_bytes)
.map_err(|e| Error::Profile(format!("{EXPORT_MANIFEST} is not valid UTF-8: {e}")))?;
parse_export_r2x(manifest)
}
Err(_) => {
let text = std::str::from_utf8(bytes)
.map_err(|e| Error::Profile(format!("export is neither a zip nor UTF-8 YAML: {e}")))?;
parse_export_r2x(text)
}
}
}
fn import_config_key(name: &str) -> Option<PathBuf> {
let normalized = name.replace('\\', "/");
if normalized.is_empty() || normalized.ends_with('/') {
return None;
}
if normalized == EXPORT_MANIFEST || normalized == "mods.yml" {
return None;
}
let lower = normalized.to_ascii_lowercase();
let probe = lower.trim_end_matches(['.', ' ']);
if BLOCKED_IMPORT_EXTENSIONS
.iter()
.any(|ext| probe.ends_with(ext))
{
return None;
}
let remapped = match normalized.strip_prefix("config/") {
Some(rest) => format!("{BEPINEX_CONFIG_DIR}/{rest}"),
None => normalized,
};
let path = PathBuf::from(remapped);
for component in path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(path)
}
fn looks_like_zip(bytes: &[u8]) -> bool {
bytes.len() >= 2 && &bytes[0..2] == b"PK"
}
fn refuse_symlink_dest(dest: &Path) -> Result<()> {
let is_symlink = fs::symlink_metadata(dest)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false);
if is_symlink {
return Err(Error::Profile(format!(
"refusing to write through a symlink at {}",
dest.display()
)));
}
Ok(())
}
pub fn extract_configs_in(target_dir: &Path, zip_bytes: &[u8]) -> Result<Vec<PathBuf>> {
if !looks_like_zip(zip_bytes) {
return Ok(Vec::new());
}
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes.to_vec()))
.map_err(|e| Error::Profile(format!("reading export archive: {e}")))?;
let mut written = Vec::new();
let mut total_written: u64 = 0;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|e| Error::Profile(format!("reading archive entry {index}: {e}")))?;
let rel = match import_config_key(entry.name()) {
Some(rel) => rel,
None => continue,
};
let bytes = read_capped_entry(&mut entry, &rel.display().to_string())?;
total_written += bytes.len() as u64;
if total_written > MAX_IMPORT_TOTAL_BYTES {
return Err(Error::ExtractLimitExceeded(format!(
"import exceeds the {MAX_IMPORT_TOTAL_BYTES}-byte cumulative import cap"
)));
}
let dest = target_dir.join(&rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
}
refuse_symlink_dest(&dest)?;
fs::write(&dest, &bytes)
.map_err(|e| Error::Profile(format!("writing {}: {}", dest.display(), e)))?;
written.push(rel);
}
Ok(written)
}
#[allow(clippy::too_many_arguments)]
pub async fn import_r2z_in(
target_dir: &Path,
cache_base: &Path,
eco: &crate::ecosystem::Ecosystem,
index: &crate::models::PackageIndex,
client: &ThunderstoreClient,
game: &str,
zip_bytes: &[u8],
installed_at_time: u64,
) -> Result<ImportedMods> {
let export = read_export(zip_bytes)?;
let desired: Vec<String> = export
.mods
.iter()
.map(|entry| format!("{}-{}", entry.name, entry.version))
.collect();
let protect: Vec<String> = export
.mods
.iter()
.filter(|entry| !entry.enabled)
.map(|entry| entry.name.clone())
.collect();
let mut batch = crate::profile::InstallBatch::new(desired, protect);
batch.enable_requested = export
.mods
.iter()
.filter(|entry| entry.enabled)
.map(|entry| entry.name.clone())
.collect();
let outcome = crate::profile::install_batch(
target_dir,
cache_base,
eco,
index,
client,
game,
&batch,
installed_at_time,
)
.await?;
if let Some((name, error)) = outcome.failed.into_iter().next() {
return Err(Error::Profile(format!(
"importing {name}: {error}; the import is unfinished (its config files were \
not restored)"
)));
}
extract_configs_in(target_dir, zip_bytes)?;
Ok(ImportedMods {
installed: outcome.succeeded,
unchanged: outcome.unchanged,
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ImportedMods {
pub installed: Vec<String>,
pub unchanged: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
pub async fn import_code_in(
target_dir: &Path,
cache_base: &Path,
eco: &crate::ecosystem::Ecosystem,
index: &crate::models::PackageIndex,
client: &ThunderstoreClient,
game: &str,
code: &str,
installed_at_time: u64,
) -> Result<ImportedMods> {
let body = client.fetch_profile_code(code).await?;
let zip_bytes = decode_profile_payload(&body)?;
import_r2z_in(
target_dir,
cache_base,
eco,
index,
client,
game,
&zip_bytes,
installed_at_time,
)
.await
}
pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
if !source_profile_dir.is_dir() {
return Err(Error::Profile(format!(
"{} is not a profile directory",
source_profile_dir.display()
)));
}
ensure_disjoint_trees(source_profile_dir, target_dir)?;
copy_tree(source_profile_dir, target_dir)
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct AdoptedProfile {
pub adopted: Vec<String>,
pub reinstalled: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
pub async fn adopt_r2modman_dir_in(
source_profile_dir: &Path,
target_dir: &Path,
cache_base: &Path,
eco: &crate::ecosystem::Ecosystem,
client: &ThunderstoreClient,
game: &str,
installed_at_time: u64,
) -> Result<AdoptedProfile> {
import_r2modman_dir(source_profile_dir, target_dir)?;
let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
.iter()
.map(|entry| entry.name.clone())
.collect();
let unrecorded: Vec<String> = adopted
.iter()
.filter(|name| eco.modloader_package(name).is_some())
.filter(|name| !crate::install::state_file_path(target_dir, name).exists())
.cloned()
.collect();
if unrecorded.is_empty() {
return Ok(AdoptedProfile {
adopted,
reinstalled: unrecorded,
});
}
let index = client.get_manifest().await?;
for full_name in &unrecorded {
crate::profile::install_named_in(
target_dir,
cache_base,
eco,
&index,
client,
game,
full_name,
true,
installed_at_time,
)
.await?;
}
Ok(AdoptedProfile {
adopted,
reinstalled: unrecorded,
})
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct AdoptOutcome {
pub adopted: Vec<String>,
pub unchanged: Vec<String>,
pub remaining: Vec<String>,
pub swept: Vec<PathBuf>,
pub failed: Vec<(String, Error)>,
}
pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
if desired.is_empty() {
return Ok(false);
}
let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
Ok(
desired
.iter()
.any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
)
}
#[allow(clippy::too_many_arguments)]
pub async fn adopt_names_in(
target_dir: &Path,
cache_base: &Path,
eco: &crate::ecosystem::Ecosystem,
index: &crate::models::PackageIndex,
client: &ThunderstoreClient,
game: &str,
desired: &[String],
installed_at_time: u64,
) -> Result<AdoptOutcome> {
let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
let batch_outcome = crate::profile::install_batch(
target_dir,
cache_base,
eco,
index,
client,
game,
&batch,
installed_at_time,
)
.await?;
let failed = batch_outcome.failed;
let failed_names: std::collections::BTreeSet<&str> =
failed.iter().map(|(name, _)| name.as_str()).collect();
let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
let mut adopted: Vec<String> = Vec::new();
let mut unchanged: Vec<String> = Vec::new();
let mut remaining: Vec<String> = Vec::new();
for name in desired {
let Some(entry) = crate::profile::modlist::find(&recorded, name) else {
remaining.push(name.clone());
continue;
};
if failed_names.contains(name.as_str()) {
continue;
}
if entry.installed_at_time == installed_at_time {
adopted.push(name.clone());
} else {
unchanged.push(name.clone());
}
}
adopted.sort();
adopted.dedup();
unchanged.sort();
unchanged.dedup();
remaining.sort();
remaining.dedup();
if !remaining.is_empty() {
return Ok(AdoptOutcome {
adopted,
unchanged,
remaining,
swept: Vec::new(),
failed,
});
}
let game_profile = eco
.game(game)
.and_then(|g| g.profile())
.ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
let keep: std::collections::BTreeSet<String> =
recorded.iter().map(|entry| entry.name.clone()).collect();
let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
Ok(AdoptOutcome {
adopted,
unchanged,
remaining,
swept,
failed,
})
}
pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
let source_at = resolve_as_far_as_it_exists(source);
let target_at = resolve_as_far_as_it_exists(target);
if source_at == target_at {
return Err(Error::Profile(format!(
"refusing to copy {} onto itself: every file would be truncated to zero \
bytes",
source.display()
)));
}
if source_at.starts_with(&target_at) {
return Err(Error::Profile(format!(
"refusing to copy {} into {}: the source lives inside the destination, so \
the copy would overwrite the tree it is reading",
source.display(),
target.display()
)));
}
if target_at.starts_with(&source_at) {
return Err(Error::Profile(format!(
"refusing to copy {} into {}: the destination lives inside the source, so \
the copy would recurse into itself",
source.display(),
target.display()
)));
}
Ok(())
}
fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if let Ok(canonical) = canonicalize_missing(path) {
return canonical;
}
for ancestor in path.ancestors().skip(1) {
let Ok(canonical_ancestor) = ancestor.canonicalize() else {
continue;
};
let Ok(remainder) = path.strip_prefix(ancestor) else {
continue;
};
return canonical_ancestor.join(remainder);
}
path.to_path_buf()
}
fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)
.map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
for entry in
fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
{
let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
if metadata.file_type().is_symlink() {
continue;
}
let target = dst.join(entry.file_name());
if metadata.is_dir() {
copy_tree(&path, &target)?;
} else {
refuse_symlink_dest(&target)?;
if same_file(&path, &target) {
continue;
}
fs::copy(&path, &target)
.map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportSource {
R2modmanDir(PathBuf),
Archive(PathBuf),
Code(String),
}
impl ImportSource {
pub fn classify(source: &str) -> Self {
let path = Path::new(source);
if path.is_dir() {
return Self::R2modmanDir(path.to_path_buf());
}
if path.is_file() {
return Self::Archive(path.to_path_buf());
}
Self::Code(source.to_string())
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct ImportOutcome {
pub source: ImportSource,
pub installed: Vec<String>,
pub unchanged: Vec<String>,
pub reinstalled: Vec<String>,
}
pub async fn import_in(
target_dir: &Path,
cache_base: &Path,
eco: &crate::ecosystem::Ecosystem,
client: &ThunderstoreClient,
game: &str,
source: &str,
installed_at_time: u64,
) -> Result<ImportOutcome> {
let source = ImportSource::classify(source);
if let ImportSource::R2modmanDir(dir) = &source {
let adopted = adopt_r2modman_dir_in(
dir,
target_dir,
cache_base,
eco,
client,
game,
installed_at_time,
)
.await?;
let mut installed = adopted.adopted;
installed.sort();
installed.dedup();
return Ok(ImportOutcome {
source,
installed,
unchanged: Vec::new(),
reinstalled: adopted.reinstalled,
});
}
let index = client.get_manifest().await?;
let imported = match &source {
ImportSource::Archive(path) => {
let bytes =
fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
import_r2z_in(
target_dir,
cache_base,
eco,
&index,
client,
game,
&bytes,
installed_at_time,
)
.await?
}
ImportSource::Code(code) => {
import_code_in(
target_dir,
cache_base,
eco,
&index,
client,
game,
code,
installed_at_time,
)
.await?
}
ImportSource::R2modmanDir(_) => {
unreachable!("R2modmanDir returns from the early branch above this match")
}
};
Ok(ImportOutcome {
source,
installed: imported.installed,
unchanged: imported.unchanged,
reinstalled: Vec::new(),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn import_r2z(
base: &Path,
game: &str,
profile_name: &str,
eco: &crate::ecosystem::Ecosystem,
index: &crate::models::PackageIndex,
client: &ThunderstoreClient,
zip_bytes: &[u8],
installed_at_time: u64,
) -> Result<ImportedMods> {
import_r2z_in(
&crate::profile::layout::profile_dir(base, game, profile_name),
base,
eco,
index,
client,
game,
zip_bytes,
installed_at_time,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn import_code(
base: &Path,
game: &str,
profile_name: &str,
eco: &crate::ecosystem::Ecosystem,
index: &crate::models::PackageIndex,
client: &ThunderstoreClient,
code: &str,
installed_at_time: u64,
) -> Result<ImportedMods> {
import_code_in(
&crate::profile::layout::profile_dir(base, game, profile_name),
base,
eco,
index,
client,
game,
code,
installed_at_time,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
entry.enabled = enabled;
entry
}
fn sample_version() -> crate::models::Version {
let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
"version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
"date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
"uuid4":"u","file_size":1}"#;
serde_json::from_str(json).unwrap()
}
#[test]
fn import_source_classifies_a_directory_a_file_and_a_code() {
let dir = tempfile::tempdir().unwrap();
let profile = dir.path().join("SomeProfile");
let archive = dir.path().join("export.r2z");
std::fs::create_dir_all(&profile).unwrap();
write_test_file(&archive, b"not really a zip");
assert!(matches!(
ImportSource::classify(profile.to_str().unwrap()),
ImportSource::R2modmanDir(path) if path == profile
));
assert!(matches!(
ImportSource::classify(archive.to_str().unwrap()),
ImportSource::Archive(path) if path == archive
));
assert!(matches!(
ImportSource::classify("abc123-def456"),
ImportSource::Code(code) if code == "abc123-def456"
));
assert!(matches!(
ImportSource::classify("/no/such/path.r2z"),
ImportSource::Code(_)
));
}
#[test]
fn export_format_maps_mods_and_preserves_enabled() {
let mods = vec![
profile_mod("Author-A", true),
profile_mod("Author-B", false),
];
let export = export_format_from_mods("MyProfile", &mods);
assert_eq!(export.profile_name, "MyProfile");
assert_eq!(export.mods.len(), 2);
assert_eq!(export.mods[0].name, "Author-A");
assert_eq!(
export.mods[0].version,
SemVer {
major: 1,
minor: 2,
patch: 3
}
);
assert!(export.mods[0].enabled);
assert!(!export.mods[1].enabled);
}
#[test]
fn serialized_r2x_uses_version_not_version_number() {
let mods = vec![profile_mod("Author-A", true)];
let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
assert!(yaml.contains("profileName: MyProfile"));
assert!(yaml.contains("name: Author-A"));
assert!(yaml.contains("version:"));
assert!(yaml.contains("major: 1"));
assert!(!yaml.contains("versionNumber"));
}
#[test]
fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
let yaml = r#"profileName: Shared
mods:
- name: Author-A
version:
major: 2
minor: 0
patch: 1
enabled: false
- name: Author-B
version:
major: 1
minor: 0
patch: 0
"#;
let export = parse_export_r2x(yaml).unwrap();
assert_eq!(export.profile_name, "Shared");
assert_eq!(export.mods.len(), 2);
assert!(!export.mods[0].enabled);
assert!(export.mods[1].enabled);
}
#[test]
fn parse_export_r2x_rejects_malformed() {
assert!(parse_export_r2x("not: a valid export").is_err());
}
fn write_test_file(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[test]
fn is_pruned_matches_r2modman_rules() {
assert!(is_pruned(Path::new("dotnet/x.json")));
assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
assert!(is_pruned(Path::new(
"BepInEx/plugins/Author-Mod/manifest.json"
)));
assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
assert!(is_pruned(Path::new("GDWeave/core/a.json")));
assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
assert!(is_pruned(Path::new("mods.yml")));
assert!(!is_pruned(Path::new(
"BepInEx/plugins/Author-Mod/settings.cfg"
)));
assert!(!is_pruned(Path::new("GDWeave/other.json")));
assert!(!is_pruned(Path::new("notes.txt")));
}
#[test]
fn is_config_file_matches_supported_extensions() {
assert!(is_config_file(Path::new("a/b.cfg")));
assert!(is_config_file(Path::new("a/b.YAML")));
assert!(is_config_file(Path::new("b.ini")));
assert!(!is_config_file(Path::new("a/b.dll")));
assert!(!is_config_file(Path::new("a/b")));
}
#[test]
fn collect_export_paths_selects_configs_and_skips_pruned() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_test_file(&root.join("mods.yml"), b"[]");
write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
write_test_file(
&root.join("BepInEx/plugins/Author-Mod/manifest.json"),
b"{}",
);
write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
let paths = collect_export_paths(root).unwrap();
let as_str: Vec<String> = paths
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(
as_str,
vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
);
}
#[test]
fn build_r2z_contains_manifest_and_configs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
let bytes = build_r2z_in(root, "MyProfile").unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names: Vec<String> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
.collect();
assert!(names.contains(&"export.r2x".to_string()));
assert!(names.contains(&"config/BepInEx.cfg".to_string()));
assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
assert!(!names.contains(&"mods.yml".to_string()));
let mut manifest = String::new();
std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
let export = parse_export_r2x(&manifest).unwrap();
assert_eq!(export.profile_name, "MyProfile");
assert_eq!(export.mods[0].name, "Author-A");
}
#[test]
fn export_to_file_uses_name_and_timestamp() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("profile");
let out = dir.path().join("exports");
crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
"MyProfile_1700000000000.r2z"
);
assert!(path.exists());
}
#[test]
fn export_to_file_rejects_unsafe_profile_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("profile");
let out = dir.path().join("exports");
crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
assert!(!dir.path().join("evil_1.r2z").exists());
}
#[test]
fn encode_profile_payload_prefixes_and_base64s() {
let payload = encode_profile_payload(b"ABC").unwrap();
assert_eq!(payload, b"#r2modman\nQUJD");
}
#[test]
fn encode_profile_payload_rejects_oversize() {
let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
assert!(matches!(
encode_profile_payload(&big),
Err(Error::SizeLimitExceeded { .. })
));
}
#[test]
fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
let payload = encode_profile_payload(b"ABC").unwrap();
assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
assert!(decode_profile_payload(b"nope\nQUJD").is_err());
}
#[test]
fn import_config_key_remaps_config_and_blocks_unsafe() {
assert_eq!(
import_config_key("config/BepInEx.cfg"),
Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
);
assert_eq!(
import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
);
assert_eq!(import_config_key("export.r2x"), None);
assert_eq!(import_config_key("mods.yml"), None);
assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
assert_eq!(import_config_key("../escape.cfg"), None);
}
#[test]
fn import_config_key_blocks_trailing_dot_and_space_bypass() {
let blocked = [
"evil.dll.",
"evil.dll ",
"evil.dll. . ",
"evil.DLL",
"evil.cfg.dll",
];
for name in blocked {
assert_eq!(
import_config_key(name),
None,
"{name} should have been blocked"
);
}
assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
}
#[test]
fn read_export_accepts_r2z_and_bare_r2x() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
}
#[test]
fn extract_configs_writes_remapped_files_only() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source");
let target = dir.path().join("target");
crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
write_test_file(
&source.join("BepInEx/plugins/Author-A/settings.cfg"),
b"mine",
);
let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
let written = extract_configs_in(&target, &zip_bytes).unwrap();
assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
assert!(
target
.join("BepInEx/plugins/Author-A/settings.cfg")
.exists()
);
assert!(!target.join("export.r2x").exists());
assert_eq!(written.len(), 2);
}
#[test]
fn extract_configs_treats_non_zip_input_as_no_configs() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
assert!(written.is_empty());
}
#[test]
fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let corrupt = b"PK\x03\x04not a real zip".to_vec();
assert!(extract_configs_in(&target, &corrupt).is_err());
}
#[test]
#[cfg(unix)]
fn extract_configs_refuses_to_write_through_a_symlink() {
use std::os::unix::fs::symlink;
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("secret.cfg");
std::fs::write(&secret, b"original").unwrap();
let source = tempfile::tempdir().unwrap();
crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
write_test_file(
&source.path().join("BepInEx/config/BepInEx.cfg"),
b"new-content",
);
let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
let target = tempfile::tempdir().unwrap();
let dest = target.path().join("BepInEx/config/BepInEx.cfg");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
symlink(&secret, &dest).unwrap();
let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
assert!(matches!(err, Error::Profile(_)));
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
}
fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file(name, options).unwrap();
zip.write_all(&vec![0u8; inflated_len]).unwrap();
zip.finish().unwrap().into_inner()
}
#[test]
fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
let bytes = zip_with_one_zero_filled_entry(
"BepInEx/config/Big.cfg",
(MAX_IMPORT_ENTRY_BYTES + 1) as usize,
);
let dir = tempfile::tempdir().unwrap();
let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
assert!(matches!(err, Error::ExtractLimitExceeded(_)));
}
#[test]
fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let chunk = vec![0u8; per_entry_len];
for i in 0..entries_needed {
zip
.start_file(format!("BepInEx/config/{i}.cfg"), options)
.unwrap();
zip.write_all(&chunk).unwrap();
}
let bytes = zip.finish().unwrap().into_inner();
let dir = tempfile::tempdir().unwrap();
let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
assert!(matches!(err, Error::ExtractLimitExceeded(_)));
}
#[test]
fn import_r2modman_dir_adopts_an_existing_profile() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source");
let target = dir.path().join("target");
crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
import_r2modman_dir(&source, &target).unwrap();
assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
}
#[test]
#[cfg(unix)]
fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
use std::os::unix::fs::symlink;
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("secret.cfg");
std::fs::write(&secret, b"original").unwrap();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source");
let target = dir.path().join("target");
crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
write_test_file(
&source.join("BepInEx/plugins/Author-A/settings.cfg"),
b"new-content",
);
let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
symlink(&secret, &dest).unwrap();
let err = import_r2modman_dir(&source, &target).unwrap_err();
assert!(matches!(err, Error::Profile(_)));
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
}
#[test]
fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_test_file(&root.join("sub/file.txt"), b"original bytes");
copy_tree(root, root).unwrap();
let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
assert_eq!(
bytes, b"original bytes",
"a same-file copy must be skipped, not truncate the file to zero bytes"
);
}
}