minecli 0.1.0

A CLI for managing Minecraft server mods, datapacks, and plugins.
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use zip::ZipArchive;

use crate::core::server::{ContentKind, ServerType};
use crate::error::{IoResultExt, MinecliError, Result};

pub const MODRINTH_INDEX_FILE: &str = "modrinth.index.json";

#[derive(Debug, Clone, Deserialize)]
pub struct ModrinthPackIndex {
    #[serde(rename = "formatVersion")]
    pub format_version: u32,
    pub game: String,
    #[serde(rename = "versionId")]
    pub version_id: String,
    pub name: String,
    #[serde(default)]
    pub summary: Option<String>,
    #[serde(default)]
    pub files: Vec<ModrinthPackFile>,
    #[serde(default)]
    pub dependencies: BTreeMap<String, String>,
}

impl ModrinthPackIndex {
    pub fn required_server_files(&self) -> Vec<&ModrinthPackFile> {
        self.files
            .iter()
            .filter(|file| file.server_side() == SideSupport::Required)
            .collect()
    }

    pub fn optional_server_files(&self) -> Vec<&ModrinthPackFile> {
        self.files
            .iter()
            .filter(|file| file.server_side() == SideSupport::Optional)
            .collect()
    }

    pub fn is_client_only(&self) -> bool {
        !self.files.is_empty()
            && self
                .files
                .iter()
                .all(|file| file.server_side() == SideSupport::Unsupported)
    }

    pub fn validate_for_server(
        &self,
        server_type: ServerType,
        minecraft_version: &str,
    ) -> Result<()> {
        if self.format_version != 1 {
            return Err(MinecliError::message(format!(
                "unsupported Modrinth modpack format version {}",
                self.format_version
            )));
        }
        if self.game != "minecraft" {
            return Err(MinecliError::message(format!(
                "unsupported modpack game `{}`",
                self.game
            )));
        }
        if self.is_client_only() {
            return Err(MinecliError::message(format!(
                "{} is client-only and has no server-side files",
                self.name
            )));
        }
        if let Some(pack_minecraft) = self.dependencies.get("minecraft")
            && pack_minecraft != minecraft_version
        {
            return Err(MinecliError::message(format!(
                "{} targets Minecraft {}, but this server is {}",
                self.name, pack_minecraft, minecraft_version
            )));
        }
        if let Some(loader) = self.loader_dependency() {
            let compatible = match loader {
                "fabric-loader" => server_type == ServerType::Fabric,
                "quilt-loader" => server_type == ServerType::Quilt,
                "forge" => server_type == ServerType::Forge,
                "neoforge" => server_type == ServerType::NeoForge,
                _ => true,
            };
            if !compatible {
                return Err(MinecliError::message(format!(
                    "{} requires {}, but this server is {}",
                    self.name, loader, server_type
                )));
            }
        }

        Ok(())
    }

    pub fn loader_dependency(&self) -> Option<&str> {
        ["fabric-loader", "quilt-loader", "forge", "neoforge"]
            .into_iter()
            .find(|loader| self.dependencies.contains_key(*loader))
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct ModrinthPackFile {
    pub path: PathBuf,
    #[serde(default)]
    pub hashes: BTreeMap<String, String>,
    #[serde(default)]
    pub env: Option<ModrinthPackEnv>,
    #[serde(default)]
    pub downloads: Vec<String>,
    #[serde(default, rename = "fileSize")]
    pub file_size: u64,
}

impl ModrinthPackFile {
    pub fn server_side(&self) -> SideSupport {
        self.env
            .as_ref()
            .and_then(|env| env.server)
            .unwrap_or(SideSupport::Required)
    }

    pub fn content_kind(&self) -> Option<ContentKind> {
        let first = self.path.components().next()?.as_os_str().to_string_lossy();
        match first.as_ref() {
            "mods" => Some(ContentKind::Mod),
            "plugins" => Some(ContentKind::Plugin),
            _ if self
                .path
                .components()
                .any(|component| component.as_os_str() == "datapacks") =>
            {
                Some(ContentKind::Datapack)
            }
            _ => None,
        }
    }

    pub fn filename(&self) -> Result<String> {
        self.path
            .file_name()
            .and_then(|filename| filename.to_str())
            .map(ToOwned::to_owned)
            .ok_or_else(|| MinecliError::message("modpack file path has no valid filename"))
    }
}

#[derive(Debug, Clone, Copy, Deserialize)]
pub struct ModrinthPackEnv {
    #[allow(dead_code)]
    pub client: Option<SideSupport>,
    pub server: Option<SideSupport>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SideSupport {
    Required,
    Optional,
    Unsupported,
}

pub fn read_modrinth_pack(path: &Path) -> Result<ModrinthPackIndex> {
    let file = File::open(path).at(path)?;
    let mut archive = ZipArchive::new(file)
        .map_err(|error| MinecliError::message(format!("failed to read mrpack zip: {error}")))?;
    let mut index = archive.by_name(MODRINTH_INDEX_FILE).map_err(|error| {
        MinecliError::message(format!("missing {MODRINTH_INDEX_FILE}: {error}"))
    })?;
    let mut contents = String::new();
    index.read_to_string(&mut contents).map_err(|error| {
        MinecliError::message(format!("failed to read {MODRINTH_INDEX_FILE}: {error}"))
    })?;
    serde_json::from_str(&contents).map_err(MinecliError::Json)
}

pub fn copy_server_overrides(pack_path: &Path, server_dir: &Path) -> Result<usize> {
    let file = File::open(pack_path).at(pack_path)?;
    let mut archive = ZipArchive::new(file)
        .map_err(|error| MinecliError::message(format!("failed to read mrpack zip: {error}")))?;
    let mut copied = 0usize;

    for index in 0..archive.len() {
        let mut file = archive.by_index(index).map_err(|error| {
            MinecliError::message(format!("failed to read mrpack entry: {error}"))
        })?;
        let name = file.name().to_owned();
        let Some(relative) = name
            .strip_prefix("server-overrides/")
            .or_else(|| name.strip_prefix("overrides/"))
        else {
            continue;
        };
        if relative.is_empty() || file.is_dir() {
            continue;
        }
        let relative = PathBuf::from(relative);
        validate_relative_path(&relative)?;
        let target = server_dir.join(&relative);
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent).at(parent)?;
        }
        let mut output = File::create(&target).at(&target)?;
        std::io::copy(&mut file, &mut output).at(&target)?;
        copied += 1;
    }

    Ok(copied)
}

pub fn validate_relative_path(path: &Path) -> Result<()> {
    if path.as_os_str().is_empty() || path.is_absolute() {
        return Err(MinecliError::message(format!(
            "unsafe modpack path: {}",
            path.display()
        )));
    }
    if path.components().any(|component| {
        matches!(
            component,
            std::path::Component::ParentDir | std::path::Component::RootDir
        )
    }) {
        return Err(MinecliError::message(format!(
            "unsafe modpack path: {}",
            path.display()
        )));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::io::Write;

    use zip::write::FileOptions;

    use crate::core::modpack::{copy_server_overrides, read_modrinth_pack, validate_relative_path};
    use crate::core::server::ServerType;

    #[test]
    fn parses_server_side_files_from_mrpack() {
        let temp = tempfile::tempdir().unwrap();
        let pack = temp.path().join("pack.mrpack");
        write_pack(&pack, sample_index());

        let index = read_modrinth_pack(&pack).unwrap();

        assert_eq!(index.name, "Test Pack");
        assert_eq!(index.required_server_files().len(), 1);
        assert_eq!(index.optional_server_files().len(), 1);
        assert!(!index.is_client_only());
        index
            .validate_for_server(ServerType::Fabric, "1.21.5")
            .unwrap();
    }

    #[test]
    fn detects_client_only_mrpack() {
        let temp = tempfile::tempdir().unwrap();
        let pack = temp.path().join("pack.mrpack");
        write_pack(
            &pack,
            r#"{
                "formatVersion": 1,
                "game": "minecraft",
                "versionId": "client",
                "name": "Client Pack",
                "files": [{
                    "path": "mods/client.jar",
                    "hashes": {"sha512": "abc"},
                    "downloads": ["https://cdn.modrinth.com/data/a/versions/b/client.jar"],
                    "fileSize": 1,
                    "env": {"client": "required", "server": "unsupported"}
                }],
                "dependencies": {"minecraft": "1.21.5", "fabric-loader": "0.16.0"}
            }"#,
        );

        let index = read_modrinth_pack(&pack).unwrap();

        assert!(index.is_client_only());
        assert!(
            index
                .validate_for_server(ServerType::Fabric, "1.21.5")
                .unwrap_err()
                .to_string()
                .contains("client-only")
        );
    }

    #[test]
    fn copies_safe_server_overrides() {
        let temp = tempfile::tempdir().unwrap();
        let pack = temp.path().join("pack.mrpack");
        let file = std::fs::File::create(&pack).unwrap();
        let mut writer = zip::ZipWriter::new(file);
        writer
            .start_file::<_, ()>("modrinth.index.json", FileOptions::default())
            .unwrap();
        writer.write_all(sample_index().as_bytes()).unwrap();
        writer
            .start_file::<_, ()>(
                "server-overrides/config/server.toml",
                FileOptions::default(),
            )
            .unwrap();
        writer.write_all(b"config").unwrap();
        writer.finish().unwrap();

        let copied = copy_server_overrides(&pack, temp.path()).unwrap();

        assert_eq!(copied, 1);
        assert_eq!(
            std::fs::read(temp.path().join("config/server.toml")).unwrap(),
            b"config"
        );
    }

    #[test]
    fn rejects_unsafe_modpack_paths() {
        assert!(validate_relative_path(std::path::Path::new("../server.properties")).is_err());
    }

    fn write_pack(path: &std::path::Path, index: &str) {
        let file = std::fs::File::create(path).unwrap();
        let mut writer = zip::ZipWriter::new(file);
        writer
            .start_file::<_, ()>("modrinth.index.json", FileOptions::default())
            .unwrap();
        writer.write_all(index.as_bytes()).unwrap();
        writer.finish().unwrap();
    }

    fn sample_index() -> &'static str {
        r#"{
            "formatVersion": 1,
            "game": "minecraft",
            "versionId": "pack-version",
            "name": "Test Pack",
            "summary": "A server-compatible pack",
            "files": [
                {
                    "path": "mods/server.jar",
                    "hashes": {"sha512": "abc"},
                    "downloads": ["https://cdn.modrinth.com/data/a/versions/b/server.jar"],
                    "fileSize": 12,
                    "env": {"client": "required", "server": "required"}
                },
                {
                    "path": "mods/optional.jar",
                    "hashes": {"sha512": "def"},
                    "downloads": ["https://cdn.modrinth.com/data/a/versions/b/optional.jar"],
                    "fileSize": 13,
                    "env": {"client": "optional", "server": "optional"}
                },
                {
                    "path": "mods/client.jar",
                    "hashes": {"sha512": "ghi"},
                    "downloads": ["https://cdn.modrinth.com/data/a/versions/b/client.jar"],
                    "fileSize": 14,
                    "env": {"client": "required", "server": "unsupported"}
                }
            ],
            "dependencies": {"minecraft": "1.21.5", "fabric-loader": "0.16.0"}
        }"#
    }
}