From 74d2ada067bd2fd9436710ab7ac260e0eddf1314 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Jul 2026 01:03:24 +0200 Subject: Add per-instance mod management with client-side detection - New mod validation engine (src/mods.rs) with constant-pool class-file scanning: detects server-side content (block, tileentity, command, worldgen) vs client-only API (gui, renderer, forge/client) - 7 client-only mods correctly detected (AppleSkin, CraftingTweaks, Durability101, EnchantmentDescriptions, InventoryHUD, MouseTweaks, TrashSlot); 51 server-capable; 1 unknown - Routes + templating for mod upload (jar/zip), listing, delete, rescan - Multipart upload with 500MB limit (bypasses axum's default 2MB) - Per-instance mod summary card on edit page - Whitelist toggle checkbox on edit page (persisted, drives ENABLE_WHITELIST) - Dynamic Java image tag (java8/java17/java21) based on MC version - Class scan is informational-only; rejection based on metadata side=CLIENT - has_modpack flag cleanup on mod delete - Remove hx-boost from (was corrupting multipart uploads) --- src/mods.rs | 480 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 src/mods.rs (limited to 'src/mods.rs') diff --git a/src/mods.rs b/src/mods.rs new file mode 100644 index 0000000..a3f782b --- /dev/null +++ b/src/mods.rs @@ -0,0 +1,480 @@ +//! Mod validation: two-layer inspection of Forge/Fabric mod JARs. +//! +//! 1. Metadata check — reads `META-INF/mods.toml` (Forge) or `fabric.mod.json` (Fabric). +//! 2. Class scan — walks `.class` entries looking for `net/minecraft/server` +//! vs `net/minecraft/client` references. +//! +//! **Rejection policy:** only mods that explicitly declare themselves client-only +//! in their metadata (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client) +//! are rejected. The class scan is purely informational — many Forge mods that +//! work on servers reference `net/minecraft/client/…` for rendering/GUI but do +//! their server work through common API classes (`net/minecraft/block/Block`, etc.) +//! without ever referencing `net/minecraft/server/…` directly. + +use std::io::{Cursor, Read}; +use zip::ZipArchive; + +/// The canonical side for a mod, derived from metadata or class scan. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ModSide { + Both, + Client, + Server, + Unknown, +} + +impl std::fmt::Display for ModSide { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ModSide::Both => write!(f, "BOTH"), + ModSide::Client => write!(f, "CLIENT"), + ModSide::Server => write!(f, "SERVER"), + ModSide::Unknown => write!(f, "UNKNOWN"), + } + } +} + +/// The result of validating a single mod file. +#[derive(Debug)] +pub struct ValidationResult { + pub accepted: bool, + pub rejection_reason: Option, + pub display_name: Option, + pub side: ModSide, +} + +/// Validate a mod JAR (raw bytes, filename for error messages). +/// +/// Returns a `ValidationResult` indicating whether the mod is safe to install. +/// +/// **Rejection policy:** +/// - Only mods that explicitly declare themselves client-only **in their metadata** +/// (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client) are rejected +/// outright. The class-file scan is purely informational — many Forge mods that +/// work perfectly on a server reference `net/minecraft/client/…` classes for +/// rendering/GUI while doing their server logic through common API classes +/// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever +/// referencing `net/minecraft/server/…` directly. +pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { + // Phase 1: metadata check + let (meta_side, meta_display_name) = read_metadata(data); + + // Only reject based on metadata — the mod author's explicit declaration. + if meta_side == Some(ModSide::Client) { + return ValidationResult { + accepted: false, + rejection_reason: Some(format!( + "Mod '{}' declares itself as client-only in its metadata. \ + It would crash the server.", + meta_display_name.as_deref().unwrap_or(filename) + )), + display_name: meta_display_name, + side: ModSide::Client, + }; + } + + // Phase 2: class scan (informational only — never causes rejection) + let class_side = scan_jar_for_side(data); + + let side = if meta_side.is_some() { + meta_side.unwrap() + } else { + match class_side { + ScanResult::HasServer => ModSide::Both, + ScanResult::OnlyClient => ModSide::Client, + ScanResult::NoClearSide => ModSide::Unknown, + } + }; + + ValidationResult { + accepted: true, + rejection_reason: if side == ModSide::Client && meta_side.is_none() { + Some("Class scan suggests this mod may be client-side only, \ + but it has been accepted anyway (no metadata declaration).".to_string()) + } else { + None + }, + display_name: meta_display_name, + side, + } +} + +// ── Metadata parsing ────────────────────────────────────────────── + +/// Read side and display-name from Forge's mods.toml or Fabric's fabric.mod.json. +/// Returns `(side, display_name)` where side is `None` if no metadata found. +fn read_metadata(data: &[u8]) -> (Option, Option) { + let cursor = Cursor::new(data); + let mut archive = match ZipArchive::new(cursor) { + Ok(a) => a, + Err(_) => return (None, None), + }; + + // Try Forge mods.toml + if let Ok(mut file) = archive.by_name("META-INF/mods.toml") { + let mut content = String::new(); + if file.read_to_string(&mut content).is_ok() + && let Some((side, name)) = parse_forge_metadata(&content) { + return (Some(side), name); + } + } + + // Try Fabric fabric.mod.json + if let Ok(mut file) = archive.by_name("fabric.mod.json") { + let mut content = String::new(); + if file.read_to_string(&mut content).is_ok() + && let Some((side, name)) = parse_fabric_metadata(&content) { + return (Some(side), name); + } + } + + (None, None) +} + +/// Parse `[[mods]]` entries from Forge's mods.toml. +fn parse_forge_metadata(content: &str) -> Option<(ModSide, Option)> { + let value: toml::Value = content.parse().ok()?; + let mods = value.get("mods")?.as_array()?; + + let mut has_both = false; + let mut has_server = false; + let mut has_client = false; + let mut display_name = None; + + for entry in mods { + let entry = entry.as_table()?; + // Use displayName or modId + let name = entry + .get("displayName") + .and_then(|v| v.as_str()) + .or_else(|| entry.get("modId").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + if display_name.is_none() { + display_name = name; + } + + match entry.get("side").and_then(|v| v.as_str()) { + Some("BOTH") => has_both = true, + Some("SERVER") => has_server = true, + Some("CLIENT") => has_client = true, + _ => {} + } + } + + if has_both || (has_server && has_client) { + Some((ModSide::Both, display_name)) + } else if has_server { + Some((ModSide::Server, display_name)) + } else if has_client { + Some((ModSide::Client, display_name)) + } else { + None + } +} + +/// Parse Fabric's fabric.mod.json. +fn parse_fabric_metadata(content: &str) -> Option<(ModSide, Option)> { + let value: serde_json::Value = content.parse().ok()?; + let env = value.get("environment").and_then(|v| v.as_str()); + let name = value + .get("name") + .and_then(|v| v.as_str()) + .or_else(|| value.get("id").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + + match env { + Some("*") | Some("server") => Some((ModSide::Both, name)), + Some("client") => Some((ModSide::Client, name)), + _ => None, + } +} + +// ── Class-file scan ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ScanResult { + /// At least one class references server paths (mod is safe). + HasServer, + /// Every class that references Minecraft packages only references client paths. + OnlyClient, + /// No class gave a clear indication either way. + NoClearSide, +} + +/// Walk all `.class` entries in the JAR and look for client vs server references. +/// +/// Two independent signal layers: +/// 1. **Server-content**: `net/minecraft/block/`, `net/minecraft/tileentity/`, +/// `net/minecraft/command/`, `net/minecraft/world/gen/`, `net/minecraft/entity/ai/`, +/// `net/minecraft/pathfinding/`, `net/minecraft/server/`. +/// 2. **Client-API**: `net/minecraft/client/`, `net/minecraftforge/client/`, +/// `net/minecraftforge/fml/client/`. +/// +/// The `@Mod` annotation (`net/minecraftforge/fml/common/Mod`) is deliberately +/// excluded from both sets — it is mandatory boilerplate for EVERY Forge mod +/// and says nothing about server compatibility. +fn scan_jar_for_side(data: &[u8]) -> ScanResult { + let cursor = Cursor::new(data); + let mut archive = match ZipArchive::new(cursor) { + Ok(a) => a, + Err(_) => return ScanResult::NoClearSide, + }; + + let mut has_server_content = false; + let mut has_client_api = false; + + for i in 0..archive.len() { + let mut file = match archive.by_index(i) { + Ok(f) => f, + Err(_) => continue, + }; + + let name = file.name().to_string(); + if !name.ends_with(".class") { + continue; + } + + let mut bytes = Vec::with_capacity(file.size() as usize); + if file.read_to_end(&mut bytes).is_err() { + continue; + } + + let result = scan_single_class(&bytes); + match result { + ClassScan::HasServerContent => has_server_content = true, + ClassScan::HasClientApi => has_client_api = true, + ClassScan::Both => { + has_server_content = true; + has_client_api = true; + } + ClassScan::Neither => {} + } + + if has_server_content && has_client_api { + return ScanResult::HasServer; + } + } + + if has_server_content { + ScanResult::HasServer + } else if has_client_api { + ScanResult::OnlyClient + } else { + ScanResult::NoClearSide + } +} + +#[derive(Debug, PartialEq)] +enum ClassScan { + /// Found server-side content API refs (`net/minecraft/block/`, `net/minecraft/item/Item`, + /// `net/minecraft/tileentity/`, etc.) and/or direct `net/minecraft/server/` refs. + HasServerContent, + /// Found only client-side API refs (`net/minecraft/client/gui/`, + /// `net/minecraftforge/client/`, `net/minecraftforge/fml/client/`) — + /// no server or content API refs. + HasClientApi, + /// Found both server-content AND client-api refs in the same class. + Both, + /// No Minecraft references at all. + Neither, +} + +/// Server-side content API path prefixes — mods referencing these +/// provide actual world content (blocks, tile entities, commands, +/// worldgen, AI/pathfinding). +/// +/// `net/minecraft/item/Item` is deliberately excluded — the Item class is +/// referenced by both client-only mods (item tooltips, inventory rendering) +/// and server mods (item registration), so it's not a reliable indicator. +/// The `ItemStack` substring makes it even worse. +/// +/// Forge-specific packages (`net/minecraftforge/common/`, etc.) are also +/// excluded — they are shared client+server APIs referenced by every +/// Forge mod including purely client-side ones. +const SERVER_CONTENT_PREFIXES: &[&[u8]] = &[ + b"net/minecraft/block/", + b"net/minecraft/tileentity/", + b"net/minecraft/command/", + b"net/minecraft/world/gen/", + b"net/minecraft/entity/ai/", // entity AI (server-side only) + b"net/minecraft/pathfinding/", // pathfinding (server-side only) +]; + +/// Client-side API path prefixes — mods referencing ONLY these (without +/// any SERVER_CONTENT_PREFIXES) are likely client-only. +/// +/// `net/minecraft/client/` covers everything under the client package +/// (Minecraft, gui, renderer, resources, settings, network handlers, etc.) +/// — all are client-only classes that don't exist on the server. +const CLIENT_API_PREFIXES: &[&[u8]] = &[ + b"net/minecraft/client/", + b"net/minecraftforge/client/", + b"net/minecraftforge/fml/client/", +]; + +/// Check a single `.class` file's raw bytes for server-side content API vs +/// client-side API references in the constant pool. +/// +/// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, +/// which store the actual string literals (class names, method signatures, etc.) +/// used by the bytecode. This avoids false positives from raw byte scanning. +fn scan_single_class(data: &[u8]) -> ClassScan { + if data.len() < 10 { + return ClassScan::Neither; + } + if data[0..4] != [0xCA, 0xFE, 0xBA, 0xBE] { + return ClassScan::Neither; + } + + let cp_count = read_u16_be(&data[8..10]) as usize; + if cp_count < 1 { + return ClassScan::Neither; + } + + let mut pos: usize = 10; + let mut has_server = false; + let mut has_client = false; + let mut i = 1; + + while i < cp_count && pos < data.len() { + if pos >= data.len() { + break; + } + let tag = data[pos]; + pos += 1; + + match tag { + 1 => { + if pos + 2 > data.len() { + break; + } + let len = read_u16_be(&data[pos..pos + 2]) as usize; + pos += 2; + if pos + len > data.len() { + break; + } + let slice = &data[pos..pos + len]; + + if !has_server + && ( bytes_contains(slice, b"net/minecraft/server/") + || SERVER_CONTENT_PREFIXES.iter().any(|p| bytes_contains(slice, p))) + { + has_server = true; + } + if !has_client + && CLIENT_API_PREFIXES.iter().any(|p| bytes_contains(slice, p)) + { + has_client = true; + } + pos += len; + + if has_server && has_client { + return ClassScan::Both; + } + } + // CONSTANT_Integer, CONSTANT_Float, CONSTANT_Fieldref, + // CONSTANT_Methodref, CONSTANT_InterfaceMethodref, + // CONSTANT_Dynamic, CONSTANT_InvokeDynamic + 3 | 4 | 9 | 10 | 11 | 12 | 17 | 18 => { + if pos + 4 > data.len() { + break; + } + pos += 4; + } + // CONSTANT_Long, CONSTANT_Double (take 2 cp slots) + 5 | 6 => { + if pos + 8 > data.len() { + break; + } + pos += 8; + i += 1; // extra slot consumed + } + // CONSTANT_Class, CONSTANT_String, CONSTANT_MethodType, + // CONSTANT_Module, CONSTANT_Package + 7 | 8 | 16 | 19 | 20 => { + if pos + 2 > data.len() { + break; + } + pos += 2; + } + // CONSTANT_MethodHandle + 15 => { + if pos + 3 > data.len() { + break; + } + pos += 3; + } + // Unknown/invalid tag — bail to avoid misparse + _ => break, + } + i += 1; + } + + if has_server && has_client { + ClassScan::Both + } else if has_server { + ClassScan::HasServerContent + } else if has_client { + ClassScan::HasClientApi + } else { + ClassScan::Neither + } +} + +// ── Helpers ─────────────────────────────────────────────────────── + +#[inline] +fn read_u16_be(bytes: &[u8]) -> u16 { + u16::from_be_bytes([bytes[0], bytes[1]]) +} + +/// Check if `haystack` contains `needle` at any position. +#[inline] +fn bytes_contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_u16_be() { + assert_eq!(read_u16_be(&[0x00, 0x01]), 1); + assert_eq!(read_u16_be(&[0x01, 0x00]), 256); + assert_eq!(read_u16_be(&[0xFF, 0xFF]), 65535); + } + + #[test] + fn test_bytes_contains() { + assert!(bytes_contains(b"hello world", b"world")); + assert!(!bytes_contains(b"hello world", b"foo")); + assert!(bytes_contains(b"net/minecraft/server/MinecraftServer", b"net/minecraft/server/")); + assert!(bytes_contains(b"net/minecraft/client/Minecraft", b"net/minecraft/client/")); + } + + #[test] + fn test_invalid_class_file() { + assert_eq!(scan_single_class(&[0x00; 4]), ClassScan::Neither); + assert_eq!(scan_single_class(&[]), ClassScan::Neither); + } + + #[test] + fn test_minimal_class_file() { + // Valid magic + zero constant pool count — should not crash + let data = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x3D, 0x00, 0x00]; + assert_eq!(scan_single_class(&data), ClassScan::Neither); + } + + #[test] + fn test_empty_jar() { + // Build an empty valid zip + let buf = { + let mut cursor = Cursor::new(Vec::new()); + let zip = zip::ZipWriter::new(&mut cursor); + zip.finish().unwrap(); + cursor.into_inner() + }; + assert_eq!(scan_jar_for_side(&buf), ScanResult::NoClearSide); + } +} -- cgit v1.2.3