summaryrefslogtreecommitdiffstats
path: root/src/mods.rs
blob: 10799f0596c6c1979df7fc7985da3037f34a2e46 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
//! 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<String>,
    pub display_name: Option<String>,
    pub side: ModSide,
}

/// Maximum size for a single mod JAR file (500 MB).
const MAX_MOD_SIZE_BYTES: usize = 500 * 1024 * 1024;

/// 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 {
    // Reject oversized files early to prevent DoS
    if data.len() > MAX_MOD_SIZE_BYTES {
        return ValidationResult {
            accepted: false,
            rejection_reason: Some(format!(
                "File too large: {} bytes (maximum is {} MB)",
                data.len(),
                MAX_MOD_SIZE_BYTES / (1024 * 1024)
            )),
            display_name: None,
            side: ModSide::Unknown,
        };
    }
    // 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<ModSide>, Option<String>) {
    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<String>)> {
    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<String>)> {
    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,
}

/// Maximum number of class files to scan inside a single JAR (prevents DoS).
const MAX_CLASSES_TO_SCAN: usize = 10_000;

/// 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;
    let mut scanned_count = 0_usize;

    for i in 0..archive.len() {
        if scanned_count >= MAX_CLASSES_TO_SCAN {
            tracing::warn!("Reached max class scan limit ({MAX_CLASSES_TO_SCAN}), stopping");
            break;
        }
        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;
        }

        scanned_count += 1;

        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.
/// Maximum constant pool entries to scan in a single class file (prevents crafted-file DoS).
const MAX_CP_ENTRIES: usize = 10_000;

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 !(1..=MAX_CP_ENTRIES).contains(&cp_count) {
        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);
    }
}