summaryrefslogtreecommitdiffstats
path: root/src/mods.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-29 01:03:24 +0200
committersalmonik <salmonik@tuta.io>2026-07-29 01:03:24 +0200
commit74d2ada067bd2fd9436710ab7ac260e0eddf1314 (patch)
tree1f791bb895a611d8e729a735ba098a647808a157 /src/mods.rs
parentcreate example config on startup (diff)
downloadminecraft-ui-74d2ada067bd2fd9436710ab7ac260e0eddf1314.tar.gz
minecraft-ui-74d2ada067bd2fd9436710ab7ac260e0eddf1314.zip
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 <body> (was corrupting multipart uploads)
Diffstat (limited to 'src/mods.rs')
-rw-r--r--src/mods.rs480
1 files changed, 480 insertions, 0 deletions
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 @@
1//! Mod validation: two-layer inspection of Forge/Fabric mod JARs.
2//!
3//! 1. Metadata check — reads `META-INF/mods.toml` (Forge) or `fabric.mod.json` (Fabric).
4//! 2. Class scan — walks `.class` entries looking for `net/minecraft/server`
5//! vs `net/minecraft/client` references.
6//!
7//! **Rejection policy:** only mods that explicitly declare themselves client-only
8//! in their metadata (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client)
9//! are rejected. The class scan is purely informational — many Forge mods that
10//! work on servers reference `net/minecraft/client/…` for rendering/GUI but do
11//! their server work through common API classes (`net/minecraft/block/Block`, etc.)
12//! without ever referencing `net/minecraft/server/…` directly.
13
14use std::io::{Cursor, Read};
15use zip::ZipArchive;
16
17/// The canonical side for a mod, derived from metadata or class scan.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum ModSide {
20 Both,
21 Client,
22 Server,
23 Unknown,
24}
25
26impl std::fmt::Display for ModSide {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 ModSide::Both => write!(f, "BOTH"),
30 ModSide::Client => write!(f, "CLIENT"),
31 ModSide::Server => write!(f, "SERVER"),
32 ModSide::Unknown => write!(f, "UNKNOWN"),
33 }
34 }
35}
36
37/// The result of validating a single mod file.
38#[derive(Debug)]
39pub struct ValidationResult {
40 pub accepted: bool,
41 pub rejection_reason: Option<String>,
42 pub display_name: Option<String>,
43 pub side: ModSide,
44}
45
46/// Validate a mod JAR (raw bytes, filename for error messages).
47///
48/// Returns a `ValidationResult` indicating whether the mod is safe to install.
49///
50/// **Rejection policy:**
51/// - Only mods that explicitly declare themselves client-only **in their metadata**
52/// (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client) are rejected
53/// outright. The class-file scan is purely informational — many Forge mods that
54/// work perfectly on a server reference `net/minecraft/client/…` classes for
55/// rendering/GUI while doing their server logic through common API classes
56/// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever
57/// referencing `net/minecraft/server/…` directly.
58pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult {
59 // Phase 1: metadata check
60 let (meta_side, meta_display_name) = read_metadata(data);
61
62 // Only reject based on metadata — the mod author's explicit declaration.
63 if meta_side == Some(ModSide::Client) {
64 return ValidationResult {
65 accepted: false,
66 rejection_reason: Some(format!(
67 "Mod '{}' declares itself as client-only in its metadata. \
68 It would crash the server.",
69 meta_display_name.as_deref().unwrap_or(filename)
70 )),
71 display_name: meta_display_name,
72 side: ModSide::Client,
73 };
74 }
75
76 // Phase 2: class scan (informational only — never causes rejection)
77 let class_side = scan_jar_for_side(data);
78
79 let side = if meta_side.is_some() {
80 meta_side.unwrap()
81 } else {
82 match class_side {
83 ScanResult::HasServer => ModSide::Both,
84 ScanResult::OnlyClient => ModSide::Client,
85 ScanResult::NoClearSide => ModSide::Unknown,
86 }
87 };
88
89 ValidationResult {
90 accepted: true,
91 rejection_reason: if side == ModSide::Client && meta_side.is_none() {
92 Some("Class scan suggests this mod may be client-side only, \
93 but it has been accepted anyway (no metadata declaration).".to_string())
94 } else {
95 None
96 },
97 display_name: meta_display_name,
98 side,
99 }
100}
101
102// ── Metadata parsing ──────────────────────────────────────────────
103
104/// Read side and display-name from Forge's mods.toml or Fabric's fabric.mod.json.
105/// Returns `(side, display_name)` where side is `None` if no metadata found.
106fn read_metadata(data: &[u8]) -> (Option<ModSide>, Option<String>) {
107 let cursor = Cursor::new(data);
108 let mut archive = match ZipArchive::new(cursor) {
109 Ok(a) => a,
110 Err(_) => return (None, None),
111 };
112
113 // Try Forge mods.toml
114 if let Ok(mut file) = archive.by_name("META-INF/mods.toml") {
115 let mut content = String::new();
116 if file.read_to_string(&mut content).is_ok()
117 && let Some((side, name)) = parse_forge_metadata(&content) {
118 return (Some(side), name);
119 }
120 }
121
122 // Try Fabric fabric.mod.json
123 if let Ok(mut file) = archive.by_name("fabric.mod.json") {
124 let mut content = String::new();
125 if file.read_to_string(&mut content).is_ok()
126 && let Some((side, name)) = parse_fabric_metadata(&content) {
127 return (Some(side), name);
128 }
129 }
130
131 (None, None)
132}
133
134/// Parse `[[mods]]` entries from Forge's mods.toml.
135fn parse_forge_metadata(content: &str) -> Option<(ModSide, Option<String>)> {
136 let value: toml::Value = content.parse().ok()?;
137 let mods = value.get("mods")?.as_array()?;
138
139 let mut has_both = false;
140 let mut has_server = false;
141 let mut has_client = false;
142 let mut display_name = None;
143
144 for entry in mods {
145 let entry = entry.as_table()?;
146 // Use displayName or modId
147 let name = entry
148 .get("displayName")
149 .and_then(|v| v.as_str())
150 .or_else(|| entry.get("modId").and_then(|v| v.as_str()))
151 .map(|s| s.to_string());
152 if display_name.is_none() {
153 display_name = name;
154 }
155
156 match entry.get("side").and_then(|v| v.as_str()) {
157 Some("BOTH") => has_both = true,
158 Some("SERVER") => has_server = true,
159 Some("CLIENT") => has_client = true,
160 _ => {}
161 }
162 }
163
164 if has_both || (has_server && has_client) {
165 Some((ModSide::Both, display_name))
166 } else if has_server {
167 Some((ModSide::Server, display_name))
168 } else if has_client {
169 Some((ModSide::Client, display_name))
170 } else {
171 None
172 }
173}
174
175/// Parse Fabric's fabric.mod.json.
176fn parse_fabric_metadata(content: &str) -> Option<(ModSide, Option<String>)> {
177 let value: serde_json::Value = content.parse().ok()?;
178 let env = value.get("environment").and_then(|v| v.as_str());
179 let name = value
180 .get("name")
181 .and_then(|v| v.as_str())
182 .or_else(|| value.get("id").and_then(|v| v.as_str()))
183 .map(|s| s.to_string());
184
185 match env {
186 Some("*") | Some("server") => Some((ModSide::Both, name)),
187 Some("client") => Some((ModSide::Client, name)),
188 _ => None,
189 }
190}
191
192// ── Class-file scan ───────────────────────────────────────────────
193
194#[derive(Debug, Clone, Copy, PartialEq)]
195enum ScanResult {
196 /// At least one class references server paths (mod is safe).
197 HasServer,
198 /// Every class that references Minecraft packages only references client paths.
199 OnlyClient,
200 /// No class gave a clear indication either way.
201 NoClearSide,
202}
203
204/// Walk all `.class` entries in the JAR and look for client vs server references.
205///
206/// Two independent signal layers:
207/// 1. **Server-content**: `net/minecraft/block/`, `net/minecraft/tileentity/`,
208/// `net/minecraft/command/`, `net/minecraft/world/gen/`, `net/minecraft/entity/ai/`,
209/// `net/minecraft/pathfinding/`, `net/minecraft/server/`.
210/// 2. **Client-API**: `net/minecraft/client/`, `net/minecraftforge/client/`,
211/// `net/minecraftforge/fml/client/`.
212///
213/// The `@Mod` annotation (`net/minecraftforge/fml/common/Mod`) is deliberately
214/// excluded from both sets — it is mandatory boilerplate for EVERY Forge mod
215/// and says nothing about server compatibility.
216fn scan_jar_for_side(data: &[u8]) -> ScanResult {
217 let cursor = Cursor::new(data);
218 let mut archive = match ZipArchive::new(cursor) {
219 Ok(a) => a,
220 Err(_) => return ScanResult::NoClearSide,
221 };
222
223 let mut has_server_content = false;
224 let mut has_client_api = false;
225
226 for i in 0..archive.len() {
227 let mut file = match archive.by_index(i) {
228 Ok(f) => f,
229 Err(_) => continue,
230 };
231
232 let name = file.name().to_string();
233 if !name.ends_with(".class") {
234 continue;
235 }
236
237 let mut bytes = Vec::with_capacity(file.size() as usize);
238 if file.read_to_end(&mut bytes).is_err() {
239 continue;
240 }
241
242 let result = scan_single_class(&bytes);
243 match result {
244 ClassScan::HasServerContent => has_server_content = true,
245 ClassScan::HasClientApi => has_client_api = true,
246 ClassScan::Both => {
247 has_server_content = true;
248 has_client_api = true;
249 }
250 ClassScan::Neither => {}
251 }
252
253 if has_server_content && has_client_api {
254 return ScanResult::HasServer;
255 }
256 }
257
258 if has_server_content {
259 ScanResult::HasServer
260 } else if has_client_api {
261 ScanResult::OnlyClient
262 } else {
263 ScanResult::NoClearSide
264 }
265}
266
267#[derive(Debug, PartialEq)]
268enum ClassScan {
269 /// Found server-side content API refs (`net/minecraft/block/`, `net/minecraft/item/Item`,
270 /// `net/minecraft/tileentity/`, etc.) and/or direct `net/minecraft/server/` refs.
271 HasServerContent,
272 /// Found only client-side API refs (`net/minecraft/client/gui/`,
273 /// `net/minecraftforge/client/`, `net/minecraftforge/fml/client/`) —
274 /// no server or content API refs.
275 HasClientApi,
276 /// Found both server-content AND client-api refs in the same class.
277 Both,
278 /// No Minecraft references at all.
279 Neither,
280}
281
282/// Server-side content API path prefixes — mods referencing these
283/// provide actual world content (blocks, tile entities, commands,
284/// worldgen, AI/pathfinding).
285///
286/// `net/minecraft/item/Item` is deliberately excluded — the Item class is
287/// referenced by both client-only mods (item tooltips, inventory rendering)
288/// and server mods (item registration), so it's not a reliable indicator.
289/// The `ItemStack` substring makes it even worse.
290///
291/// Forge-specific packages (`net/minecraftforge/common/`, etc.) are also
292/// excluded — they are shared client+server APIs referenced by every
293/// Forge mod including purely client-side ones.
294const SERVER_CONTENT_PREFIXES: &[&[u8]] = &[
295 b"net/minecraft/block/",
296 b"net/minecraft/tileentity/",
297 b"net/minecraft/command/",
298 b"net/minecraft/world/gen/",
299 b"net/minecraft/entity/ai/", // entity AI (server-side only)
300 b"net/minecraft/pathfinding/", // pathfinding (server-side only)
301];
302
303/// Client-side API path prefixes — mods referencing ONLY these (without
304/// any SERVER_CONTENT_PREFIXES) are likely client-only.
305///
306/// `net/minecraft/client/` covers everything under the client package
307/// (Minecraft, gui, renderer, resources, settings, network handlers, etc.)
308/// — all are client-only classes that don't exist on the server.
309const CLIENT_API_PREFIXES: &[&[u8]] = &[
310 b"net/minecraft/client/",
311 b"net/minecraftforge/client/",
312 b"net/minecraftforge/fml/client/",
313];
314
315/// Check a single `.class` file's raw bytes for server-side content API vs
316/// client-side API references in the constant pool.
317///
318/// We parse the constant pool minimally to find `CONSTANT_Utf8` entries,
319/// which store the actual string literals (class names, method signatures, etc.)
320/// used by the bytecode. This avoids false positives from raw byte scanning.
321fn scan_single_class(data: &[u8]) -> ClassScan {
322 if data.len() < 10 {
323 return ClassScan::Neither;
324 }
325 if data[0..4] != [0xCA, 0xFE, 0xBA, 0xBE] {
326 return ClassScan::Neither;
327 }
328
329 let cp_count = read_u16_be(&data[8..10]) as usize;
330 if cp_count < 1 {
331 return ClassScan::Neither;
332 }
333
334 let mut pos: usize = 10;
335 let mut has_server = false;
336 let mut has_client = false;
337 let mut i = 1;
338
339 while i < cp_count && pos < data.len() {
340 if pos >= data.len() {
341 break;
342 }
343 let tag = data[pos];
344 pos += 1;
345
346 match tag {
347 1 => {
348 if pos + 2 > data.len() {
349 break;
350 }
351 let len = read_u16_be(&data[pos..pos + 2]) as usize;
352 pos += 2;
353 if pos + len > data.len() {
354 break;
355 }
356 let slice = &data[pos..pos + len];
357
358 if !has_server
359 && ( bytes_contains(slice, b"net/minecraft/server/")
360 || SERVER_CONTENT_PREFIXES.iter().any(|p| bytes_contains(slice, p)))
361 {
362 has_server = true;
363 }
364 if !has_client
365 && CLIENT_API_PREFIXES.iter().any(|p| bytes_contains(slice, p))
366 {
367 has_client = true;
368 }
369 pos += len;
370
371 if has_server && has_client {
372 return ClassScan::Both;
373 }
374 }
375 // CONSTANT_Integer, CONSTANT_Float, CONSTANT_Fieldref,
376 // CONSTANT_Methodref, CONSTANT_InterfaceMethodref,
377 // CONSTANT_Dynamic, CONSTANT_InvokeDynamic
378 3 | 4 | 9 | 10 | 11 | 12 | 17 | 18 => {
379 if pos + 4 > data.len() {
380 break;
381 }
382 pos += 4;
383 }
384 // CONSTANT_Long, CONSTANT_Double (take 2 cp slots)
385 5 | 6 => {
386 if pos + 8 > data.len() {
387 break;
388 }
389 pos += 8;
390 i += 1; // extra slot consumed
391 }
392 // CONSTANT_Class, CONSTANT_String, CONSTANT_MethodType,
393 // CONSTANT_Module, CONSTANT_Package
394 7 | 8 | 16 | 19 | 20 => {
395 if pos + 2 > data.len() {
396 break;
397 }
398 pos += 2;
399 }
400 // CONSTANT_MethodHandle
401 15 => {
402 if pos + 3 > data.len() {
403 break;
404 }
405 pos += 3;
406 }
407 // Unknown/invalid tag — bail to avoid misparse
408 _ => break,
409 }
410 i += 1;
411 }
412
413 if has_server && has_client {
414 ClassScan::Both
415 } else if has_server {
416 ClassScan::HasServerContent
417 } else if has_client {
418 ClassScan::HasClientApi
419 } else {
420 ClassScan::Neither
421 }
422}
423
424// ── Helpers ───────────────────────────────────────────────────────
425
426#[inline]
427fn read_u16_be(bytes: &[u8]) -> u16 {
428 u16::from_be_bytes([bytes[0], bytes[1]])
429}
430
431/// Check if `haystack` contains `needle` at any position.
432#[inline]
433fn bytes_contains(haystack: &[u8], needle: &[u8]) -> bool {
434 haystack.windows(needle.len()).any(|w| w == needle)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn test_read_u16_be() {
443 assert_eq!(read_u16_be(&[0x00, 0x01]), 1);
444 assert_eq!(read_u16_be(&[0x01, 0x00]), 256);
445 assert_eq!(read_u16_be(&[0xFF, 0xFF]), 65535);
446 }
447
448 #[test]
449 fn test_bytes_contains() {
450 assert!(bytes_contains(b"hello world", b"world"));
451 assert!(!bytes_contains(b"hello world", b"foo"));
452 assert!(bytes_contains(b"net/minecraft/server/MinecraftServer", b"net/minecraft/server/"));
453 assert!(bytes_contains(b"net/minecraft/client/Minecraft", b"net/minecraft/client/"));
454 }
455
456 #[test]
457 fn test_invalid_class_file() {
458 assert_eq!(scan_single_class(&[0x00; 4]), ClassScan::Neither);
459 assert_eq!(scan_single_class(&[]), ClassScan::Neither);
460 }
461
462 #[test]
463 fn test_minimal_class_file() {
464 // Valid magic + zero constant pool count — should not crash
465 let data = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x3D, 0x00, 0x00];
466 assert_eq!(scan_single_class(&data), ClassScan::Neither);
467 }
468
469 #[test]
470 fn test_empty_jar() {
471 // Build an empty valid zip
472 let buf = {
473 let mut cursor = Cursor::new(Vec::new());
474 let zip = zip::ZipWriter::new(&mut cursor);
475 zip.finish().unwrap();
476 cursor.into_inner()
477 };
478 assert_eq!(scan_jar_for_side(&buf), ScanResult::NoClearSide);
479 }
480}