From f28dbf42728596dfefca5786b23ef55aee14d31c Mon Sep 17 00:00:00 2001 From: Anthony <53194069+aanthoonyy@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:49:06 -0700 Subject: [PATCH 1/2] curseforge export cli --- src/cli/export.rs | 126 ++++++++++++++ src/cli/mod.rs | 3 + src/instance/export.rs | 269 ++++++++++++++++++++++++++++++ src/instance/import/curseforge.rs | 48 +++--- src/instance/mod.rs | 1 + src/net/curseforge.rs | 50 +++++- 6 files changed, 470 insertions(+), 27 deletions(-) create mode 100644 src/cli/export.rs create mode 100644 src/instance/export.rs diff --git a/src/cli/export.rs b/src/cli/export.rs new file mode 100644 index 0000000..6bd9c07 --- /dev/null +++ b/src/cli/export.rs @@ -0,0 +1,126 @@ +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; + +use crate::instance::InstanceManager; +use crate::instance::export::{ExportOptions, export_curseforge}; + +pub fn command() -> Command { + Command::new("export") + .about("Export a CurseForge-compatible modpack ZIP") + .arg(Arg::new("instance").help("Instance name")) + .arg( + Arg::new("directory") + .long("directory") + .requires_all(["minecraft", "loader"]) + .help("Standalone Minecraft game folder"), + ) + .group( + ArgGroup::new("source") + .args(["instance", "directory"]) + .required(true), + ) + .arg(Arg::new("output").long("output").short('o').required(true)) + .arg(Arg::new("name").long("name")) + .arg(Arg::new("version").long("version").default_value("1.0.0")) + .arg(Arg::new("author").long("author").default_value("")) + .arg( + Arg::new("minecraft") + .long("minecraft") + .requires("directory"), + ) + .arg( + Arg::new("loader") + .long("loader") + .requires("directory") + .value_parser(["vanilla", "forge", "fabric", "neoforge", "quilt"]), + ) + .arg( + Arg::new("loader-version") + .long("loader-version") + .requires("directory"), + ) + .arg( + Arg::new("include") + .long("include") + .action(ArgAction::Append) + .help("Additional game-relative file or folder; repeatable"), + ) + .arg( + Arg::new("bundle-unmatched") + .long("bundle-unmatched") + .action(ArgAction::SetTrue) + .help("Bundle custom/unmatched mods in overrides"), + ) +} + +pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box> { + let mut lock = None; + let (root, name, game_version, loader, loader_version) = if let Some(directory) = + matches.get_one::("directory") + { + let game_version = matches + .get_one::("minecraft") + .ok_or("--directory requires --minecraft")? + .clone(); + let loader = super::instance::parse_loader(super::utils::required_arg(matches, "loader")?)?; + let root = crate::config::settings::resolve_path(directory); + let name = root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Modpack") + .to_owned(); + ( + root, + name, + game_version, + loader, + matches.get_one::("loader-version").cloned(), + ) + } else { + let name = super::utils::required_arg(matches, "instance")?; + let settings = crate::config::SETTINGS.read(); + let manager = InstanceManager::new( + settings.paths.resolve_instances_dir(), + settings.paths.resolve_meta_dir(), + ); + drop(settings); + lock = Some(crate::instance::runtime::lock_instance( + &manager.instances_dir, + name, + )?); + let config = manager.load_one(name)?; + ( + crate::storage::InstancePaths::new(manager.instances_dir.join(name)).minecraft(), + config.name, + config.game_version, + config.loader, + config.loader_version, + ) + }; + let options = ExportOptions { + name: matches.get_one::("name").cloned().unwrap_or(name), + version: super::utils::required_arg(matches, "version")?.to_owned(), + author: super::utils::required_arg(matches, "author")?.to_owned(), + game_version, + loader, + loader_version, + include: matches + .get_many::("include") + .into_iter() + .flatten() + .map(std::path::PathBuf::from) + .collect(), + bundle_unmatched: matches.get_flag("bundle-unmatched"), + }; + let output = + crate::config::settings::resolve_path(super::utils::required_arg(matches, "output")?); + println!( + "Exporting '{}': identifying mods and packaging selected files...", + options.name + ); + export_curseforge(&root, &output, &options) + .await + .map_err(|error| error.to_string())?; + drop(lock); + println!("Exported '{}' to {}", options.name, output.display()); + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 805d92c..6c59522 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ mod account; mod content; +mod export; mod import; mod instance; mod log; @@ -78,6 +79,7 @@ pub async fn init() { Some(("log", sub_matches)) => log::handle_log(sub_matches).await, Some(("version", sub_matches)) => version::handle_version(sub_matches).await, Some(("import", sub_matches)) => import::handle_import(sub_matches).await, + Some(("export", sub_matches)) => export::handle_export(sub_matches).await, _ => Ok(()), }; @@ -164,6 +166,7 @@ fn build_command() -> Command { ), ) .subcommand(build_content_command("mod", "mods")) + .subcommand(export::command()) .subcommand(build_content_command("pack", "resource packs")) .subcommand(build_content_command("shader", "shaders")) .subcommand( diff --git a/src/instance/export.rs b/src/instance/export.rs new file mode 100644 index 0000000..c101ae3 --- /dev/null +++ b/src/instance/export.rs @@ -0,0 +1,269 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use super::ModLoader; +use super::content::manifest::fingerprint; +use super::import::curseforge::{Manifest, ManifestFile, ManifestLoader, Minecraft}; + +type ExportResult = Result>; + +pub struct ExportOptions { + pub name: String, + pub version: String, + pub author: String, + pub game_version: String, + pub loader: ModLoader, + pub loader_version: Option, + pub include: Vec, + pub bundle_unmatched: bool, +} + +const DEFAULT_PATHS: &[&str] = &[ + "mods", + "config", + "defaultconfigs", + "kubejs", + "scripts", + "resourcepacks", + "shaderpacks", + "datapacks", +]; + +fn collect(root: &Path, relative: &Path, files: &mut BTreeSet) -> ExportResult<()> { + if !crate::storage::safe_relative_path(relative) + || relative.components().any(|part| match part { + Component::Normal(name) => name + .to_str() + .is_none_or(|value| value.contains(['\\', ':'])), + _ => true, + }) + { + return Err(format!("Invalid export path: {}", relative.display()).into()); + } + let mut ancestor = root.to_path_buf(); + for component in relative.components() { + ancestor.push(component); + if std::fs::symlink_metadata(&ancestor)? + .file_type() + .is_symlink() + { + return Err(format!("Export does not follow symlinks: {}", relative.display()).into()); + } + } + let path = root.join(relative); + let metadata = std::fs::symlink_metadata(&path)?; + if metadata.is_dir() { + for entry in std::fs::read_dir(path)? { + collect(root, &relative.join(entry?.file_name()), files)?; + } + } else if metadata.is_file() { + if !relative.to_string_lossy().ends_with(".disabled") { + files.insert(relative.to_owned()); + } + } else { + return Err(format!("Not a regular file: {}", path.display()).into()); + } + Ok(()) +} + +fn selected_files(root: &Path, include: &[PathBuf]) -> ExportResult> { + let mut files = BTreeSet::new(); + for name in DEFAULT_PATHS { + match std::fs::symlink_metadata(root.join(name)) { + Ok(_) => collect(root, Path::new(name), &mut files)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + for path in include { + collect(root, path, &mut files)?; + } + Ok(files) +} + +pub async fn export_curseforge( + root: &Path, + output: &Path, + options: &ExportOptions, +) -> ExportResult<()> { + for (label, value) in [ + ("Pack name", &options.name), + ("Pack version", &options.version), + ("Minecraft version", &options.game_version), + ] { + if value.trim().is_empty() { + return Err(format!("{label} must not be empty").into()); + } + } + if options.loader != ModLoader::Vanilla + && options + .loader_version + .as_ref() + .is_none_or(|version| version.trim().is_empty()) + { + return Err("A loader version is required for a modded pack".into()); + } + let root = root.canonicalize()?; + if !root.is_dir() { + return Err("Game folder must be a directory".into()); + } + let parent = output + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + if parent.canonicalize()?.starts_with(&root) { + return Err("Export ZIP must be outside the game folder".into()); + } + if output.exists() { + return Err(format!("Output already exists: {}", output.display()).into()); + } + let files = selected_files(&root, &options.include)?; + let mut hashes = BTreeMap::new(); + let mut sha1_hashes = BTreeMap::new(); + for path in &files { + if path.parent() == Some(Path::new("mods")) + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("jar")) + { + let hash = fingerprint(&root.join(path))?; + let value = hash + .hash("curseforge") + .ok_or("Missing CurseForge fingerprint")? + .parse::()?; + sha1_hashes.insert( + path.clone(), + hash.hash("sha1") + .ok_or("Missing SHA-1 fingerprint")? + .to_owned(), + ); + hashes.insert(path.clone(), value); + } + } + let mut matches = BTreeMap::new(); + if !hashes.is_empty() { + let key = + crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?; + let client = crate::net::HttpClient::new(); + let values = hashes + .values() + .copied() + .collect::>() + .into_iter() + .collect::>(); + for chunk in values.chunks(100) { + for (hash, version) in + crate::net::curseforge::resolve_fingerprint_versions(&client, key, chunk).await? + { + matches.insert(hash, version); + } + } + } + let mut resolved = BTreeMap::new(); + for (path, hash) in &hashes { + if let Some(version) = matches.get(hash) + && version.files.iter().any(|artifact| { + artifact + .hashes + .get("sha1") + .is_some_and(|expected| expected.eq_ignore_ascii_case(&sha1_hashes[path])) + }) + { + resolved.insert( + path.clone(), + (version.project_id.parse()?, version.id.parse()?), + ); + } + } + write_archive(&root, output, options, &files, &resolved) +} + +fn write_archive( + root: &Path, + output: &Path, + options: &ExportOptions, + files: &BTreeSet, + resolved: &BTreeMap, +) -> ExportResult<()> { + let unmatched = files + .iter() + .filter(|path| path.starts_with("mods") && !resolved.contains_key(*path)) + .collect::>(); + if !unmatched.is_empty() { + let names = unmatched + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join("\n"); + if !options.bundle_unmatched { + return Err(format!("Mods without CurseForge matches:\n{names}\nUse --bundle-unmatched to include these files in overrides/mods.").into()); + } + eprintln!("Bundling custom/unmatched mods:\n{names}"); + } + let loaders = match options.loader { + ModLoader::Vanilla => Vec::new(), + loader => vec![ManifestLoader { + id: format!( + "{}-{}", + loader.to_string().to_lowercase(), + options + .loader_version + .as_deref() + .ok_or("Missing loader version")? + ), + primary: true, + }], + }; + let references = resolved + .values() + .copied() + .collect::>() + .into_iter() + .map(|(project_id, file_id)| ManifestFile { + project_id, + file_id, + required: true, + }) + .collect::>(); + let manifest = Manifest { + minecraft: Minecraft { + version: options.game_version.clone(), + mod_loaders: loaders, + }, + manifest_type: "minecraftModpack".to_owned(), + manifest_version: 1, + name: options.name.clone(), + version: options.version.clone(), + author: options.author.clone(), + files: references, + overrides: "overrides".to_owned(), + }; + let parent = output + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + { + let mut zip = zip::ZipWriter::new(temporary.as_file_mut()); + let zip_options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + zip.start_file("manifest.json", zip_options)?; + zip.write_all(&serde_json::to_vec_pretty(&manifest)?)?; + for path in files { + if resolved.contains_key(path) { + continue; + } + let name = path + .to_str() + .ok_or("Export paths must be UTF-8")? + .replace('\\', "/"); + zip.start_file(format!("overrides/{name}"), zip_options)?; + std::io::copy(&mut std::fs::File::open(root.join(path))?, &mut zip)?; + } + zip.finish()?; + } + temporary.as_file().sync_all()?; + temporary.persist_noclobber(output)?; + Ok(()) +} diff --git a/src/instance/import/curseforge.rs b/src/instance/import/curseforge.rs index 2cafd44..136a7ba 100644 --- a/src/instance/import/curseforge.rs +++ b/src/instance/import/curseforge.rs @@ -3,49 +3,55 @@ use std::path::Path; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::feedback::progress; use crate::instance::{InstanceConfig, InstanceManager, ModLoader}; use super::{ImportSummary, PackFormat}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct Manifest { - name: String, - version: String, - minecraft: Minecraft, +pub(crate) struct Manifest { + pub name: String, + pub version: String, + pub minecraft: Minecraft, #[serde(default)] - files: Vec, + pub author: String, + #[serde(default)] + pub manifest_type: String, + #[serde(default)] + pub manifest_version: u32, + #[serde(default)] + pub files: Vec, #[serde(default = "default_overrides")] - overrides: String, + pub overrides: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct Minecraft { - version: String, +pub(crate) struct Minecraft { + pub version: String, #[serde(default)] - mod_loaders: Vec, + pub mod_loaders: Vec, } -#[derive(Debug, Deserialize)] -struct ManifestLoader { - id: String, +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct ManifestLoader { + pub id: String, #[serde(default)] - primary: bool, + pub primary: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct ManifestFile { +pub(crate) struct ManifestFile { #[serde(rename = "projectID")] - _project_id: u64, + pub project_id: u64, #[serde(rename = "fileID")] - file_id: u64, + pub file_id: u64, #[serde(default = "required_by_default")] - required: bool, + pub required: bool, } fn default_overrides() -> String { diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 5e44a22..a63761c 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -4,6 +4,7 @@ pub mod config_sync; pub mod content; pub mod desktop; +pub mod export; pub(crate) mod glfw; pub mod import; pub mod java; diff --git a/src/net/curseforge.rs b/src/net/curseforge.rs index 8ce0682..d6ef7e6 100644 --- a/src/net/curseforge.rs +++ b/src/net/curseforge.rs @@ -228,8 +228,15 @@ struct FingerprintResponse { #[derive(Debug, Deserialize)] struct FingerprintMatch { - id: u32, - file: File, + file: FingerprintFile, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FingerprintFile { + file_fingerprint: u32, + #[serde(flatten)] + metadata: File, } #[derive(Debug, serde::Serialize)] @@ -598,13 +605,45 @@ pub async fn resolve_fingerprints( api_key: &str, fingerprints: &[u32], ) -> Result, NetError> { + resolve_fingerprints_from(client, api_key, API_BASE, fingerprints).await +} + +async fn resolve_fingerprints_from( + client: &HttpClient, + api_key: &str, + api_base: &str, + fingerprints: &[u32], +) -> Result, NetError> { + Ok( + resolve_fingerprint_versions_from(client, api_key, api_base, fingerprints) + .await? + .into_iter() + .map(|(fingerprint, version)| (fingerprint, version.project_id, version.id)) + .collect(), + ) +} + +pub(crate) async fn resolve_fingerprint_versions( + client: &HttpClient, + api_key: &str, + fingerprints: &[u32], +) -> Result, NetError> { + resolve_fingerprint_versions_from(client, api_key, API_BASE, fingerprints).await +} + +async fn resolve_fingerprint_versions_from( + client: &HttpClient, + api_key: &str, + api_base: &str, + fingerprints: &[u32], +) -> Result, NetError> { if fingerprints.is_empty() { return Ok(Vec::new()); } let response: ApiResponse = post( client, api_key, - &format!("{API_BASE}/fingerprints/{MINECRAFT_GAME_ID}"), + &format!("{api_base}/fingerprints/{MINECRAFT_GAME_ID}"), &FingerprintRequest { fingerprints }, ) .await?; @@ -614,9 +653,8 @@ pub async fn resolve_fingerprints( .into_iter() .map(|matched| { ( - matched.id, - matched.file.mod_id.to_string(), - matched.file.id.to_string(), + matched.file.file_fingerprint, + version_info(matched.file.metadata), ) }) .collect()) From eb6400a49d183943041171ea6ec6b00894d70051 Mon Sep 17 00:00:00 2001 From: Anthony <53194069+aanthoonyy@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:21:59 -0700 Subject: [PATCH 2/2] pr changes --- socksfor1-world-breaker | 1 + src/instance/export.rs | 18 ++--- src/instance/tests/export.rs | 147 +++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 9 deletions(-) create mode 160000 socksfor1-world-breaker create mode 100644 src/instance/tests/export.rs diff --git a/socksfor1-world-breaker b/socksfor1-world-breaker new file mode 160000 index 0000000..421e807 --- /dev/null +++ b/socksfor1-world-breaker @@ -0,0 +1 @@ +Subproject commit 421e8071ae9b182fca533439997b588dd16613cc diff --git a/src/instance/export.rs b/src/instance/export.rs index c101ae3..e057324 100644 --- a/src/instance/export.rs +++ b/src/instance/export.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::Write; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use super::ModLoader; use super::content::manifest::fingerprint; @@ -31,14 +31,10 @@ const DEFAULT_PATHS: &[&str] = &[ ]; fn collect(root: &Path, relative: &Path, files: &mut BTreeSet) -> ExportResult<()> { - if !crate::storage::safe_relative_path(relative) - || relative.components().any(|part| match part { - Component::Normal(name) => name - .to_str() - .is_none_or(|value| value.contains(['\\', ':'])), - _ => true, - }) - { + if !relative.to_str().is_some_and(|path| { + super::import::portable_pack_path(path) + .is_ok_and(|normalized| cfg!(windows) || normalized == path) + }) { return Err(format!("Invalid export path: {}", relative.display()).into()); } let mut ancestor = root.to_path_buf(); @@ -267,3 +263,7 @@ fn write_archive( temporary.persist_noclobber(output)?; Ok(()) } + +#[cfg(test)] +#[path = "tests/export.rs"] +mod tests; diff --git a/src/instance/tests/export.rs b/src/instance/tests/export.rs new file mode 100644 index 0000000..2e0f5ed --- /dev/null +++ b/src/instance/tests/export.rs @@ -0,0 +1,147 @@ +use super::*; + +fn options() -> ExportOptions { + ExportOptions { + name: "Exported Pack".to_owned(), + version: "1.2.3".to_owned(), + author: "Pack Author".to_owned(), + game_version: "1.20.1".to_owned(), + loader: ModLoader::Forge, + loader_version: Some("47.2.0".to_owned()), + include: vec![PathBuf::from("options.txt")], + bundle_unmatched: false, + } +} + +fn game_folder(parent: &Path) -> PathBuf { + let root = parent.join("game"); + std::fs::create_dir_all(root.join("config/nested")).unwrap(); + std::fs::write( + root.join("config/nested/settings.json"), + b"{\"enabled\":true}", + ) + .unwrap(); + std::fs::write(root.join("options.txt"), b"music:0.5").unwrap(); + root +} + +#[tokio::test] +async fn exported_overrides_round_trip_through_curseforge_import() { + for loader in [ModLoader::Vanilla, ModLoader::Forge] { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join("pack.zip"); + let mut options = options(); + options.loader = loader; + if loader == ModLoader::Vanilla { + options.loader_version = None; + } + export_curseforge(&root, &output, &options).await.unwrap(); + + let summary = super::super::import::build_summary(&output).unwrap(); + assert_eq!(summary.name, options.name); + assert_eq!(summary.pack_version, options.version); + assert_eq!(summary.game_version, options.game_version); + assert_eq!(summary.loader, options.loader); + assert_eq!(summary.loader_version, options.loader_version); + assert_eq!(summary.override_count, 2); + assert_eq!(summary.mod_count, 0); + let manager = super::super::InstanceManager::new( + temp.path().join("instances"), + temp.path().join("meta"), + ); + let config = serde_json::from_value(serde_json::json!({ + "name": summary.name, + "game_version": summary.game_version, + "loader": summary.loader, + "loader_version": summary.loader_version, + "created": "2026-01-01T00:00:00Z" + })) + .unwrap(); + super::super::import::curseforge::execute_import(&summary, &manager, &config) + .await + .unwrap(); + let imported = crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)) + .minecraft(); + for path in ["config/nested/settings.json", "options.txt"] { + assert_eq!( + std::fs::read(imported.join(path)).unwrap(), + std::fs::read(root.join(path)).unwrap() + ); + } + } +} + +#[cfg(unix)] +#[tokio::test] +async fn export_rejects_nonportable_filenames_before_writing_archive() { + for name in [ + "bad?.json", + "bad*.json", + "NUL.json", + "trailing.", + "trailing ", + r"bad\name.json", + ] { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::write(root.join("config").join(name), b"invalid").unwrap(); + let output = temp.path().join("pack.zip"); + let error = export_curseforge(&root, &output, &options()) + .await + .unwrap_err(); + assert!( + error.to_string().contains("Invalid export path"), + "{name}: {error}" + ); + assert!(!output.exists(), "{name}"); + } +} + +#[tokio::test] +async fn export_preserves_existing_output() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join("pack.zip"); + std::fs::write(&output, b"existing pack").unwrap(); + let error = export_curseforge(&root, &output, &options()) + .await + .unwrap_err(); + assert!( + error.to_string().contains("Output already exists"), + "{error}" + ); + assert_eq!(std::fs::read(&output).unwrap(), b"existing pack"); +} + +#[tokio::test] +async fn export_rejects_nonportable_include_paths() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join("pack.zip"); + for path in ["../outside", "config//nested", "config/./nested"] { + let mut options = options(); + options.include = vec![PathBuf::from(path)]; + let error = export_curseforge(&root, &output, &options) + .await + .unwrap_err(); + assert!( + error.to_string().contains("Invalid export path"), + "{path}: {error}" + ); + assert!(!output.exists()); + } +} + +#[test] +fn archive_writer_preserves_output_created_after_preflight() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join("pack.zip"); + assert!(!output.exists()); + let options = options(); + let files = selected_files(&root, &options.include).unwrap(); + std::fs::write(&output, b"another export").unwrap(); + assert!(write_archive(&root, &output, &options, &files, &BTreeMap::new()).is_err()); + assert_eq!(std::fs::read(&output).unwrap(), b"another export"); +}