Skip to main content

libmagic_rs/parser/
loader.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! File and directory loading for magic files.
5//!
6//! Provides functions for loading magic rules from individual files and
7//! directories, with automatic format detection and error handling.
8
9use log::warn;
10
11use crate::error::ParseError;
12use crate::parser::ParsedMagic;
13use crate::parser::name_table::NameTable;
14use std::path::{Path, PathBuf};
15
16use super::format::{MagicFileFormat, detect_format};
17
18/// Maximum magic file size (1 GB).
19///
20/// Applied before loading a magic file (or any file within a magic directory)
21/// into memory to prevent memory-exhaustion `DoS` from maliciously oversized
22/// inputs.
23///
24/// This value is kept in sync with `crate::io::FileBuffer::MAX_FILE_SIZE`.
25/// The constant is duplicated (rather than imported) because this module is
26/// also pulled in by `build.rs` via `#[path]` and the build script cannot
27/// reference lib-only modules such as `crate::io`. A unit test below asserts
28/// the two constants remain equal.
29pub const MAX_MAGIC_FILE_SIZE: u64 = 1024 * 1024 * 1024;
30
31/// Reads a magic file into a `String` after verifying its size does not
32/// exceed [`MAX_MAGIC_FILE_SIZE`].
33///
34/// Returns a `ParseError` if metadata cannot be read, the file exceeds the
35/// size limit, or the file contents cannot be read.
36///
37/// # Encoding
38///
39/// Magic files are parsed as byte streams (matching GNU `file`/libmagic
40/// behavior). Real-world magic files frequently contain non-UTF-8 bytes in
41/// comments and attribution lines (e.g., Latin-1 author names). Rather than
42/// rejecting such files, invalid UTF-8 sequences are replaced with U+FFFD
43/// via [`String::from_utf8_lossy`] and a warning is logged. ASCII rule
44/// syntax is preserved byte-for-byte; replacements only affect non-ASCII
45/// text which, in practice, appears almost exclusively inside comments
46/// that are stripped before tokenization.
47fn read_magic_file_bounded(path: &Path) -> Result<String, ParseError> {
48    let metadata = std::fs::metadata(path).map_err(|e| {
49        ParseError::IoError(std::io::Error::new(
50            e.kind(),
51            format!("Failed to read metadata for '{}': {}", path.display(), e),
52        ))
53    })?;
54
55    if metadata.len() > MAX_MAGIC_FILE_SIZE {
56        return Err(ParseError::invalid_syntax(
57            0,
58            format!(
59                "Magic file '{}' is too large: {} bytes (maximum allowed: {} bytes)",
60                path.display(),
61                metadata.len(),
62                MAX_MAGIC_FILE_SIZE
63            ),
64        ));
65    }
66
67    let bytes = std::fs::read(path).map_err(ParseError::from)?;
68
69    match String::from_utf8(bytes) {
70        Ok(s) => Ok(s),
71        Err(e) => {
72            warn!(
73                "Magic file '{}' contains non-UTF-8 bytes; they were replaced with U+FFFD. \
74                 Rule parsing proceeds, but replacements inside rule bodies may alter matching.",
75                path.display()
76            );
77            Ok(String::from_utf8_lossy(&e.into_bytes()).into_owned())
78        }
79    }
80}
81
82/// Loads and parses all magic files from a directory, merging them into a single rule set.
83///
84/// This function reads all regular files in the specified directory, parses each as a magic file,
85/// and combines the resulting rules into a single `Vec<MagicRule>`. Files are processed in
86/// alphabetical order by filename to ensure deterministic results.
87///
88/// # Error Handling Strategy
89///
90/// This function distinguishes between critical and non-critical errors:
91///
92/// - **Critical errors** (I/O failures, directory access issues, encoding problems):
93///   These cause immediate failure and return a `ParseError`. The function stops processing
94///   and propagates the error to the caller.
95///
96/// - **Non-critical errors** (individual file parse failures):
97///   These are logged at warn level and the file is skipped. Processing
98///   continues with remaining files.
99///
100/// # Behavior
101///
102/// - Subdirectories are skipped (not recursively processed)
103/// - Symbolic links are skipped
104/// - Empty directories return an empty rules vector
105/// - Files are processed in alphabetical order by filename
106/// - All successfully parsed rules are merged in order
107///
108/// # Examples
109///
110/// Loading a directory of magic files:
111///
112/// ```rust,no_run
113/// use libmagic_rs::parser::load_magic_directory;
114/// use std::path::Path;
115///
116/// let parsed = load_magic_directory(Path::new("/usr/share/file/magic.d"))?;
117/// println!("Loaded {} rules from directory", parsed.rules.len());
118/// # Ok::<(), libmagic_rs::ParseError>(())
119/// ```
120///
121/// Creating a Magdir-style directory structure:
122///
123/// ```rust,no_run
124/// use libmagic_rs::parser::load_magic_directory;
125/// use std::path::Path;
126///
127/// // Directory structure:
128/// // magic.d/
129/// //   ├── 01-elf
130/// //   ├── 02-archive
131/// //   └── 03-text
132///
133/// let parsed = load_magic_directory(Path::new("./magic.d"))?;
134/// // Rules from all three files are merged in alphabetical order
135/// # Ok::<(), libmagic_rs::ParseError>(())
136/// ```
137///
138/// # Errors
139///
140/// Returns `ParseError` if:
141/// - The directory does not exist or cannot be accessed
142/// - Directory entries cannot be read
143/// - A file cannot be read due to I/O errors
144/// - A file contains invalid UTF-8 encoding
145///
146/// # Panics
147///
148/// This function does not panic under normal operation.
149pub fn load_magic_directory(dir_path: &Path) -> Result<ParsedMagic, ParseError> {
150    use std::fs;
151
152    // Read directory entries
153    let entries = fs::read_dir(dir_path).map_err(|e| {
154        ParseError::invalid_syntax(
155            0,
156            format!("Failed to read directory '{}': {}", dir_path.display(), e),
157        )
158    })?;
159
160    // Collect and sort entries by filename for deterministic ordering
161    let mut file_paths: Vec<std::path::PathBuf> = Vec::new();
162    for entry in entries {
163        let entry = entry.map_err(|e| {
164            ParseError::invalid_syntax(
165                0,
166                format!(
167                    "Failed to read directory entry in '{}': {}",
168                    dir_path.display(),
169                    e
170                ),
171            )
172        })?;
173
174        let path = entry.path();
175        let file_type = entry.file_type().map_err(|e| {
176            ParseError::invalid_syntax(
177                0,
178                format!("Failed to read file type for '{}': {}", path.display(), e),
179            )
180        })?;
181
182        // Only process regular files, skip directories and symlinks.
183        // `is_file()` (not `!is_dir()`) is deliberate: sockets, FIFOs, and
184        // device nodes must also be excluded from magic-file discovery.
185        #[allow(clippy::filetype_is_file)]
186        if file_type.is_file() && !file_type.is_symlink() {
187            file_paths.push(path);
188        }
189    }
190
191    // Sort by filename for deterministic ordering
192    file_paths.sort_by_key(|path| path.file_name().map(std::ffi::OsStr::to_os_string));
193
194    // Accumulate rules and name tables from all files
195    let mut all_rules = Vec::new();
196    let mut merged_table = NameTable::empty();
197    let mut parse_failures: Vec<(PathBuf, ParseError)> = Vec::new();
198    let mut any_success = false;
199    let file_count = file_paths.len();
200
201    for path in file_paths {
202        // Read file contents (size-bounded to prevent memory exhaustion)
203        let contents = match read_magic_file_bounded(&path) {
204            Ok(contents) => contents,
205            Err(e) => {
206                // I/O errors (including oversized files) are critical
207                return Err(ParseError::invalid_syntax(
208                    0,
209                    format!("Failed to read file '{}': {}", path.display(), e),
210                ));
211            }
212        };
213
214        // Parse the file
215        match super::parse_text_magic_file(&contents) {
216            Ok(parsed) => {
217                any_success = true;
218                all_rules.extend(parsed.rules);
219                merged_table.merge(parsed.name_table);
220            }
221            Err(e) => {
222                // Track parse failures for reporting
223                parse_failures.push((path, e));
224            }
225        }
226    }
227
228    // If all files failed to parse, return an error.
229    // Use `any_success` rather than `all_rules.is_empty()` so that directories
230    // whose files parse successfully but contain only meta-type definitions
231    // (e.g. a directory of pure `name`-subroutine files) are not mistaken for
232    // complete failure.
233    if !any_success && !parse_failures.is_empty() {
234        use std::fmt::Write;
235
236        let failure_details: Vec<String> = parse_failures
237            .iter()
238            .take(3) // Limit to first 3 failures for brevity
239            .map(|(path, e)| format!("  - {}: {}", path.display(), e))
240            .collect();
241
242        let mut message = format!("All {file_count} magic file(s) in directory failed to parse");
243        if !failure_details.is_empty() {
244            message.push_str(":\n");
245            message.push_str(&failure_details.join("\n"));
246            if parse_failures.len() > 3 {
247                // fmt::Write to a String is infallible; discard the Result
248                // rather than unwrap so the no-panic policy holds regardless.
249                #[allow(clippy::let_underscore_must_use)]
250                let _ = write!(
251                    message,
252                    "\n  ... and {} more",
253                    parse_failures.len().saturating_sub(3)
254                );
255            }
256        }
257
258        return Err(ParseError::invalid_syntax(0, message));
259    }
260
261    // Log warnings for partial failures (some files parsed, some failed)
262    for (path, e) in &parse_failures {
263        warn!("Failed to parse '{}': {}", path.display(), e);
264    }
265
266    Ok(ParsedMagic {
267        rules: all_rules,
268        name_table: merged_table,
269    })
270}
271
272/// Loads magic rules from a file or directory, automatically detecting the format.
273///
274/// This is the unified entry point for loading magic rules from the filesystem. It
275/// automatically detects whether the path points to a text magic file, a directory
276/// containing magic files, or a binary compiled magic file, and dispatches to the
277/// appropriate handler.
278///
279/// # Format Detection and Handling
280///
281/// The function uses [`detect_format()`] to determine the file type and handles each
282/// format as follows:
283///
284/// - **Text format**: Reads the file contents and parses using [`super::parse_text_magic_file()`]
285/// - **Directory format**: Loads all magic files from the directory using [`load_magic_directory()`]
286/// - **Binary format**: Returns an error with guidance to use the `--use-builtin` option
287///
288/// # Arguments
289///
290/// * `path` - Path to a magic file or directory. Can be absolute or relative.
291///
292/// # Returns
293///
294/// Returns `Ok(Vec<MagicRule>)` containing all successfully parsed magic rules. For
295/// directories, rules from all files are merged in alphabetical order by filename.
296///
297/// # Errors
298///
299/// This function returns a [`ParseError`] in the following cases:
300///
301/// - **File not found**: The specified path does not exist
302/// - **Unsupported format**: The file is a binary compiled magic file (`.mgc`)
303/// - **Parse errors**: The magic file contains syntax errors or invalid rules
304/// - **I/O errors**: File system errors during reading (permissions, disk errors, etc.)
305///
306/// # Examples
307///
308/// ## Loading a text magic file
309///
310/// ```no_run
311/// use libmagic_rs::parser::load_magic_file;
312/// use std::path::Path;
313///
314/// let parsed = load_magic_file(Path::new("/usr/share/misc/magic"))?;
315/// println!("Loaded {} magic rules", parsed.rules.len());
316/// # Ok::<(), libmagic_rs::ParseError>(())
317/// ```
318///
319/// ## Loading a directory of magic files
320///
321/// ```no_run
322/// use libmagic_rs::parser::load_magic_file;
323/// use std::path::Path;
324///
325/// let parsed = load_magic_file(Path::new("/usr/share/misc/magic.d"))?;
326/// println!("Loaded {} rules from directory", parsed.rules.len());
327/// # Ok::<(), libmagic_rs::ParseError>(())
328/// ```
329///
330/// ## Handling binary format errors
331///
332/// ```no_run
333/// use libmagic_rs::parser::load_magic_file;
334/// use std::path::Path;
335///
336/// match load_magic_file(Path::new("/usr/share/misc/magic.mgc")) {
337///     Ok(parsed) => println!("Loaded {} rules", parsed.rules.len()),
338///     Err(e) => {
339///         eprintln!("Error loading magic file: {}", e);
340///         eprintln!("Hint: Use --use-builtin for binary files");
341///     }
342/// }
343/// # Ok::<(), libmagic_rs::ParseError>(())
344/// ```
345///
346/// # Security
347///
348/// This function delegates to [`super::parse_text_magic_file()`] or [`load_magic_directory()`]
349/// based on format detection. Security considerations are handled by those functions:
350///
351/// - Rule hierarchy depth is bounded during parsing
352/// - Invalid syntax is rejected with descriptive errors
353/// - Binary `.mgc` files are rejected (not parsed)
354///
355/// A 1 GB size limit (`MAX_MAGIC_FILE_SIZE`, module-internal) is enforced on each file loaded
356/// (both standalone files and files within a directory) to prevent memory
357/// exhaustion from maliciously oversized inputs. Files exceeding the limit are
358/// rejected with a `ParseError` before their contents are read.
359///
360/// # See Also
361///
362/// - [`detect_format()`] - Format detection logic
363/// - [`super::parse_text_magic_file()`] - Text file parser
364/// - [`load_magic_directory()`] - Directory loader
365pub fn load_magic_file(path: &Path) -> Result<ParsedMagic, ParseError> {
366    // Detect the magic file format
367    let format = detect_format(path)?;
368
369    // Dispatch to appropriate handler based on format
370    match format {
371        MagicFileFormat::Text => {
372            // Read file contents (size-bounded) and parse as text magic file
373            let content = read_magic_file_bounded(path)?;
374            super::parse_text_magic_file(&content)
375        }
376        MagicFileFormat::Directory => {
377            // Load all magic files from directory
378            load_magic_directory(path)
379        }
380        MagicFileFormat::Binary => {
381            // Binary compiled magic files are not supported
382            Err(ParseError::unsupported_format(
383                0,
384                "binary .mgc file",
385                "Binary compiled magic files (.mgc) are not supported for parsing.\n\
386                 Use the --use-builtin option to use the built-in magic rules instead,\n\
387                 or provide a text-based magic file or directory.",
388            ))
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    // Restriction lints without an allow-*-in-tests config option;
396    // tests create exactly one level under a fresh TempDir.
397    #![allow(clippy::create_dir)]
398
399    use super::*;
400
401    // ============================================================
402    // Tests for load_magic_directory (6+ test cases)
403    // ============================================================
404
405    #[test]
406    fn test_load_directory_critical_error_io() {
407        use std::path::Path;
408
409        let non_existent = Path::new("/this/should/not/exist/anywhere/at/all");
410        let result = load_magic_directory(non_existent);
411
412        assert!(
413            result.is_err(),
414            "Should return error for non-existent directory"
415        );
416        let err = result.unwrap_err();
417        assert!(err.to_string().contains("Failed to read directory"));
418    }
419
420    #[test]
421    fn test_load_directory_non_critical_error_parse() {
422        use std::fs;
423        use tempfile::TempDir;
424
425        let temp_dir = TempDir::new().expect("Failed to create temp dir");
426
427        // Create a valid file
428        let valid_path = temp_dir.path().join("valid.magic");
429        fs::write(&valid_path, "0 string \\x01\\x02 valid\n").expect("Failed to write valid file");
430
431        // Create an invalid file
432        let invalid_path = temp_dir.path().join("invalid.magic");
433        fs::write(&invalid_path, "this is invalid syntax\n").expect("Failed to write invalid file");
434
435        // Should succeed, loading only the valid file
436        let parsed = load_magic_directory(temp_dir.path()).expect("Should load valid files");
437
438        assert_eq!(parsed.rules.len(), 1, "Should load only valid file");
439        assert_eq!(parsed.rules[0].message, "valid");
440    }
441
442    #[test]
443    fn test_load_directory_empty_files() {
444        use std::fs;
445        use tempfile::TempDir;
446
447        let temp_dir = TempDir::new().expect("Failed to create temp dir");
448
449        // Create an empty file
450        let empty_path = temp_dir.path().join("empty.magic");
451        fs::write(&empty_path, "").expect("Failed to write empty file");
452
453        // Create a file with only comments
454        let comments_path = temp_dir.path().join("comments.magic");
455        fs::write(&comments_path, "# Just comments\n# Nothing else\n")
456            .expect("Failed to write comments file");
457
458        // Should succeed with no rules
459        let parsed = load_magic_directory(temp_dir.path()).expect("Should handle empty files");
460
461        assert_eq!(
462            parsed.rules.len(),
463            0,
464            "Empty files should contribute no rules"
465        );
466    }
467
468    #[test]
469    fn test_load_directory_binary_files() {
470        use std::fs;
471        use tempfile::TempDir;
472
473        let temp_dir = TempDir::new().expect("Failed to create temp dir");
474
475        // Create a binary file (invalid UTF-8). Lossy conversion turns this
476        // into U+FFFD characters that the grammar parser cannot interpret as
477        // a rule; the directory loader treats that as a non-critical parse
478        // failure and skips the file.
479        let binary_path = temp_dir.path().join("binary.dat");
480        fs::write(&binary_path, [0xFF, 0xFE, 0xFF, 0xFE]).expect("Failed to write binary file");
481
482        // Create a valid text file
483        let valid_path = temp_dir.path().join("valid.magic");
484        fs::write(&valid_path, "0 string \\x01\\x02 valid\n").expect("Failed to write valid file");
485
486        let parsed = load_magic_directory(temp_dir.path())
487            .expect("Directory with a binary file alongside a valid file should still load");
488
489        assert_eq!(
490            parsed.rules.len(),
491            1,
492            "Only the valid magic file should contribute rules"
493        );
494        assert_eq!(parsed.rules[0].message, "valid");
495    }
496
497    #[test]
498    fn test_load_directory_mixed_extensions() {
499        use std::fs;
500        use tempfile::TempDir;
501
502        let temp_dir = TempDir::new().expect("Failed to create temp dir");
503
504        // Create files with different extensions
505        fs::write(
506            temp_dir.path().join("file.magic"),
507            "0 string \\x01\\x02 magic\n",
508        )
509        .expect("Failed to write .magic file");
510        fs::write(
511            temp_dir.path().join("file.txt"),
512            "0 string \\x03\\x04 txt\n",
513        )
514        .expect("Failed to write .txt file");
515        fs::write(temp_dir.path().join("noext"), "0 string \\x05\\x06 noext\n")
516            .expect("Failed to write no-ext file");
517
518        let parsed = load_magic_directory(temp_dir.path())
519            .expect("Should load all files regardless of extension");
520
521        assert_eq!(
522            parsed.rules.len(),
523            3,
524            "Should process all files regardless of extension"
525        );
526
527        let messages: Vec<&str> = parsed.rules.iter().map(|r| r.message.as_str()).collect();
528        assert!(messages.contains(&"magic"));
529        assert!(messages.contains(&"txt"));
530        assert!(messages.contains(&"noext"));
531    }
532
533    #[test]
534    fn test_load_directory_alphabetical_ordering() {
535        use std::fs;
536        use tempfile::TempDir;
537
538        let temp_dir = TempDir::new().expect("Failed to create temp dir");
539
540        // Create files in non-alphabetical order - using valid magic syntax with hex escapes
541        fs::write(
542            temp_dir.path().join("03-third"),
543            "0 string \\x07\\x08\\x09 third\n",
544        )
545        .expect("Failed to write third file");
546        fs::write(
547            temp_dir.path().join("01-first"),
548            "0 string \\x01\\x02\\x03 first\n",
549        )
550        .expect("Failed to write first file");
551        fs::write(
552            temp_dir.path().join("02-second"),
553            "0 string \\x04\\x05\\x06 second\n",
554        )
555        .expect("Failed to write second file");
556
557        let parsed = load_magic_directory(temp_dir.path()).expect("Should load directory in order");
558
559        assert_eq!(parsed.rules.len(), 3);
560        // Should be sorted alphabetically by filename
561        assert_eq!(parsed.rules[0].message, "first");
562        assert_eq!(parsed.rules[1].message, "second");
563        assert_eq!(parsed.rules[2].message, "third");
564    }
565
566    // ============================================================
567    // Tests for load_magic_file (5+ test cases)
568    // ============================================================
569
570    #[test]
571    fn test_load_magic_file_text_format() {
572        use std::fs;
573        use tempfile::TempDir;
574
575        let temp_dir = TempDir::new().expect("Failed to create temp dir");
576        let magic_file = temp_dir.path().join("magic.txt");
577
578        // Create text magic file with valid content
579        fs::write(&magic_file, "0 string \\x7fELF ELF executable\n")
580            .expect("Failed to write magic file");
581
582        // Load using load_magic_file
583        let parsed = load_magic_file(&magic_file).expect("Failed to load text magic file");
584
585        assert_eq!(parsed.rules.len(), 1);
586        assert_eq!(parsed.rules[0].message, "ELF executable");
587    }
588
589    #[test]
590    fn test_load_magic_file_directory_format() {
591        use std::fs;
592        use tempfile::TempDir;
593
594        let temp_dir = TempDir::new().expect("Failed to create temp dir");
595        let magic_dir = temp_dir.path().join("magic.d");
596        fs::create_dir(&magic_dir).expect("Failed to create magic directory");
597
598        // Create multiple files in directory
599        fs::write(
600            magic_dir.join("00_elf"),
601            "0 string \\x7fELF ELF executable\n",
602        )
603        .expect("Failed to write elf file");
604        fs::write(
605            magic_dir.join("01_zip"),
606            "0 string \\x50\\x4b\\x03\\x04 ZIP archive\n",
607        )
608        .expect("Failed to write zip file");
609
610        // Load using load_magic_file
611        let parsed = load_magic_file(&magic_dir).expect("Failed to load directory");
612
613        assert_eq!(parsed.rules.len(), 2);
614        assert_eq!(parsed.rules[0].message, "ELF executable");
615        assert_eq!(parsed.rules[1].message, "ZIP archive");
616    }
617
618    #[test]
619    fn test_load_magic_file_binary_format_error() {
620        use std::fs::File;
621        use std::io::Write;
622        use tempfile::TempDir;
623
624        let temp_dir = TempDir::new().expect("Failed to create temp dir");
625        let binary_file = temp_dir.path().join("magic.mgc");
626
627        // Create binary file with .mgc magic number
628        let mut file = File::create(&binary_file).expect("Failed to create binary file");
629        let magic_number: [u8; 4] = [0x1C, 0x04, 0x1E, 0xF1]; // Little-endian 0xF11E041C
630        file.write_all(&magic_number)
631            .expect("Failed to write magic number");
632
633        // Attempt to load binary file
634        let result = load_magic_file(&binary_file);
635
636        assert!(result.is_err(), "Should fail to load binary .mgc file");
637
638        let error = result.unwrap_err();
639        let error_msg = error.to_string();
640
641        // Verify error mentions unsupported format and --use-builtin
642        assert!(
643            error_msg.contains("Binary") || error_msg.contains("binary"),
644            "Error should mention binary format: {error_msg}",
645        );
646        assert!(
647            error_msg.contains("--use-builtin") || error_msg.contains("built-in"),
648            "Error should mention --use-builtin option: {error_msg}",
649        );
650    }
651
652    #[test]
653    fn test_load_magic_file_io_error() {
654        use std::path::Path;
655
656        // Try to load non-existent file
657        let non_existent = Path::new("/this/path/should/not/exist/magic.txt");
658        let result = load_magic_file(non_existent);
659
660        assert!(result.is_err(), "Should fail for non-existent file");
661    }
662
663    #[test]
664    fn test_load_magic_file_parse_error_propagation() {
665        use std::fs;
666        use tempfile::TempDir;
667
668        let temp_dir = TempDir::new().expect("Failed to create temp dir");
669        let invalid_file = temp_dir.path().join("invalid.magic");
670
671        // Create file with invalid syntax (missing offset)
672        fs::write(&invalid_file, "string test invalid\n").expect("Failed to write invalid file");
673
674        // Attempt to load file with parse errors
675        let result = load_magic_file(&invalid_file);
676
677        assert!(result.is_err(), "Should fail for file with parse errors");
678
679        // Error should be a parse error (not I/O error)
680        let error = result.unwrap_err();
681        let error_msg = format!("{error:?}");
682        assert!(
683            error_msg.contains("InvalidSyntax") || error_msg.contains("syntax"),
684            "Error should be parse error: {error_msg}",
685        );
686    }
687
688    #[test]
689    fn test_max_magic_file_size_matches_file_buffer_limit() {
690        // Ensure the duplicated limit stays in sync with FileBuffer::MAX_FILE_SIZE.
691        // loader.rs cannot `use crate::io::FileBuffer` at module scope because
692        // build.rs pulls this file in via `#[path]`, but tests compile as part
693        // of the library and can reach it fine.
694        assert_eq!(
695            MAX_MAGIC_FILE_SIZE,
696            crate::io::FileBuffer::MAX_FILE_SIZE,
697            "MAX_MAGIC_FILE_SIZE must match FileBuffer::MAX_FILE_SIZE"
698        );
699    }
700
701    #[test]
702    fn test_load_magic_file_rejects_oversized_file() {
703        use std::fs::File;
704        use tempfile::TempDir;
705
706        let temp_dir = TempDir::new().expect("Failed to create temp dir");
707        let oversized = temp_dir.path().join("huge.magic");
708
709        // Create a sparse file whose reported size exceeds MAX_MAGIC_FILE_SIZE
710        // without actually consuming that much disk space.
711        let file = File::create(&oversized).expect("Failed to create oversized file");
712        file.set_len(MAX_MAGIC_FILE_SIZE + 1)
713            .expect("Failed to set sparse file length");
714        drop(file);
715
716        let result = load_magic_file(&oversized);
717
718        assert!(
719            result.is_err(),
720            "Loading a file larger than MAX_MAGIC_FILE_SIZE must fail"
721        );
722
723        let err_msg = result.unwrap_err().to_string();
724        assert!(
725            err_msg.contains("too large"),
726            "Error should indicate size limit violation, got: {err_msg}"
727        );
728        assert!(
729            err_msg.contains(&MAX_MAGIC_FILE_SIZE.to_string()),
730            "Error should mention the maximum allowed size, got: {err_msg}"
731        );
732    }
733
734    #[test]
735    fn test_load_magic_file_tolerates_non_utf8_in_comment() {
736        // Regression: /usr/share/file/magic/filesystems on macOS contains a
737        // Latin-1 `ß` (0xdf) in a contributor attribution comment. Previously
738        // this was rejected by `fs::read_to_string` with an opaque "stream
739        // did not contain valid UTF-8" error. The loader must now tolerate
740        // non-UTF-8 bytes in comments (and anywhere else they appear) by
741        // lossily replacing them.
742        use std::fs;
743        use tempfile::TempDir;
744
745        let temp_dir = TempDir::new().expect("Failed to create temp dir");
746        let magic_path = temp_dir.path().join("with-latin1-comment.magic");
747
748        let mut bytes: Vec<u8> = Vec::new();
749        bytes.extend_from_slice(b"# From: Thomas Wei");
750        bytes.push(0xdf); // invalid UTF-8 (Latin-1 encoding of `ß`)
751        bytes.extend_from_slice(b"schuh <thomas@example.invalid>\n");
752        bytes.extend_from_slice(b"0 string \\x7fELF ELF executable\n");
753        fs::write(&magic_path, &bytes).expect("Failed to write magic file with non-UTF-8 byte");
754
755        let parsed = load_magic_file(&magic_path)
756            .expect("Magic file with non-UTF-8 bytes in a comment must still load");
757
758        assert_eq!(
759            parsed.rules.len(),
760            1,
761            "The ELF rule should be parsed; the comment is stripped"
762        );
763        assert_eq!(parsed.rules[0].message, "ELF executable");
764    }
765
766    #[test]
767    fn test_load_directory_merges_name_tables() {
768        use std::fs;
769        use tempfile::TempDir;
770
771        let temp_dir = TempDir::new().expect("Failed to create temp dir");
772
773        // Each file defines a different named subroutine.
774        fs::write(
775            temp_dir.path().join("00_first"),
776            "0 name sub_a\n>0 byte 1 a-body\n",
777        )
778        .expect("Failed to write sub_a file");
779        fs::write(
780            temp_dir.path().join("01_second"),
781            "0 name sub_b\n>0 byte 2 b-body\n",
782        )
783        .expect("Failed to write sub_b file");
784
785        let parsed =
786            load_magic_directory(temp_dir.path()).expect("Should load both name subroutines");
787
788        // Both `name` rules are hoisted out, so top-level rules list is empty.
789        assert_eq!(parsed.rules.len(), 0);
790        assert!(parsed.name_table.get("sub_a").is_some());
791        assert!(parsed.name_table.get("sub_b").is_some());
792    }
793}