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::io::Read;
15use std::path::{Path, PathBuf};
16
17use super::format::{MagicFileFormat, detect_format, has_binary_magic_header};
18
19/// Maximum magic file size (1 GiB).
20///
21/// Applied before loading a magic file (or any file within a magic directory)
22/// into memory to prevent memory-exhaustion `DoS` from maliciously oversized
23/// inputs.
24///
25/// This value is kept in sync with `crate::io::FileBuffer::MAX_FILE_SIZE`.
26/// The constant is duplicated (rather than imported) because this module is
27/// also pulled in by `build.rs` via `#[path]` and the build script cannot
28/// reference lib-only modules such as `crate::io`. A unit test below asserts
29/// the two constants remain equal.
30pub const MAX_MAGIC_FILE_SIZE: u64 = 1024 * 1024 * 1024;
31
32/// Reads a magic file into a `String` after verifying its size does not
33/// exceed [`MAX_MAGIC_FILE_SIZE`].
34///
35/// Returns a `ParseError` if metadata cannot be read, the file exceeds the
36/// size limit, or the file contents cannot be read.
37///
38/// # Encoding
39///
40/// Magic files are parsed as byte streams (matching GNU `file`/libmagic
41/// behavior). Real-world magic files frequently contain non-UTF-8 bytes in
42/// comments and attribution lines (e.g., Latin-1 author names). Rather than
43/// rejecting such files, invalid UTF-8 sequences are replaced with U+FFFD
44/// via [`String::from_utf8_lossy`] and a warning is logged. ASCII rule
45/// syntax is preserved byte-for-byte; replacements only affect non-ASCII
46/// text which, in practice, appears almost exclusively inside comments
47/// that are stripped before tokenization.
48fn read_magic_file_bounded(path: &Path) -> Result<String, ParseError> {
49    let metadata = std::fs::metadata(path).map_err(|e| {
50        ParseError::IoError(std::io::Error::new(
51            e.kind(),
52            format!("Failed to read metadata for '{}': {}", path.display(), e),
53        ))
54    })?;
55
56    if metadata.len() > MAX_MAGIC_FILE_SIZE {
57        return Err(ParseError::invalid_syntax(
58            0,
59            format!(
60                "Magic file '{}' is too large: {} bytes (maximum allowed: {} bytes)",
61                path.display(),
62                metadata.len(),
63                MAX_MAGIC_FILE_SIZE
64            ),
65        ));
66    }
67
68    let bytes = std::fs::read(path).map_err(ParseError::from)?;
69
70    Ok(decode_magic_bytes(bytes, Some(path)))
71}
72
73/// Adds operation context to an I/O failure raised while draining a reader.
74///
75/// A reader has no path to name, so without this the caller sees only the bare
76/// underlying error with no indication of which stage produced it.
77fn reader_io_error(error: &std::io::Error) -> ParseError {
78    ParseError::IoError(std::io::Error::new(
79        error.kind(),
80        format!("Failed to read magic database from reader: {error}"),
81    ))
82}
83
84fn read_magic_reader_bounded<R: Read>(reader: R) -> Result<String, ParseError> {
85    read_magic_reader_with_limit(reader, MAX_MAGIC_FILE_SIZE)
86}
87
88fn read_magic_reader_with_limit<R: Read>(reader: R, max_size: u64) -> Result<String, ParseError> {
89    // Read one byte past the limit: that extra byte is what distinguishes
90    // "exactly at the limit" from "oversized". Without it an over-long reader
91    // would be silently truncated to max_size instead of rejected.
92    let read_limit = max_size.checked_add(1).ok_or_else(|| {
93        ParseError::invalid_syntax(0, "Magic database input size limit is too large")
94    })?;
95    let mut reader = reader.take(read_limit);
96    let mut bytes = Vec::new();
97    reader
98        .by_ref()
99        .take(4)
100        .read_to_end(&mut bytes)
101        .map_err(|error| reader_io_error(&error))?;
102    // Reject a compiled database before buffering the rest of the stream.
103    if has_binary_magic_header(&bytes) {
104        return Err(unsupported_binary_magic_error());
105    }
106    reader
107        .read_to_end(&mut bytes)
108        .map_err(|error| reader_io_error(&error))?;
109    decode_magic_bytes_with_limit(bytes, max_size)
110}
111
112fn decode_magic_bytes_bounded(bytes: Vec<u8>) -> Result<String, ParseError> {
113    decode_magic_bytes_with_limit(bytes, MAX_MAGIC_FILE_SIZE)
114}
115
116fn decode_magic_bytes_with_limit(bytes: Vec<u8>, max_size: u64) -> Result<String, ParseError> {
117    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_size {
118        return Err(ParseError::invalid_syntax(
119            0,
120            format!("Magic database input is too large: more than {max_size} bytes"),
121        ));
122    }
123    if has_binary_magic_header(&bytes) {
124        return Err(unsupported_binary_magic_error());
125    }
126    Ok(decode_magic_bytes(bytes, None))
127}
128
129fn decode_magic_bytes(bytes: Vec<u8>, source: Option<&Path>) -> String {
130    match String::from_utf8(bytes) {
131        Ok(content) => content,
132        Err(error) => {
133            if let Some(path) = source {
134                warn!(
135                    "Magic file '{}' contains non-UTF-8 bytes; they were replaced with U+FFFD. \
136                     Rule parsing proceeds, but replacements inside rule bodies may alter matching.",
137                    path.display()
138                );
139            } else {
140                warn!(
141                    "Magic database input contains non-UTF-8 bytes; they were replaced with U+FFFD. \
142                     Rule parsing proceeds, but replacements inside rule bodies may alter matching."
143                );
144            }
145            String::from_utf8_lossy(&error.into_bytes()).into_owned()
146        }
147    }
148}
149
150fn unsupported_binary_magic_error() -> ParseError {
151    ParseError::unsupported_format(
152        0,
153        "binary .mgc file",
154        "Binary compiled magic files (.mgc) are not supported for parsing.\n\
155         Use the --use-builtin option to use the built-in magic rules instead,\n\
156         or provide a text-based magic file or directory.",
157    )
158}
159
160/// Whether `contents` contains at least one actual rule line -- a non-blank
161/// line that is neither a comment (`#`) nor a `!:` metadata directive
162/// (`!:mime`, `!:strength`, ...). Used by [`load_magic_directory`] to tell a
163/// file whose rules were all skipped as unparseable (unusable) apart from a
164/// genuinely empty, comment-only, or directive-only file (valid, contributes
165/// no rules). `!:` directives are stripped during preprocessing and are not
166/// rules, so counting them here would misclassify a directive-only file as
167/// "had rules but all were skipped".
168fn has_rule_lines(contents: &str) -> bool {
169    contents.lines().any(|line| {
170        let trimmed = line.trim();
171        !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("!:")
172    })
173}
174
175/// Loads and parses all magic files from a directory, merging them into a single rule set.
176///
177/// This function reads all regular files in the specified directory, parses each as a magic file,
178/// and combines the resulting rules into a single `Vec<MagicRule>`. Files are processed in
179/// alphabetical order by filename to ensure deterministic results.
180///
181/// # Error Handling Strategy
182///
183/// This function distinguishes between critical and non-critical errors:
184///
185/// - **Critical errors** (I/O failures, directory access issues, encoding problems):
186///   These cause immediate failure and return a `ParseError`. The function stops processing
187///   and propagates the error to the caller.
188///
189/// - **Non-critical errors** (individual file parse failures):
190///   These are logged at warn level and the file is skipped. Processing
191///   continues with remaining files.
192///
193/// # Behavior
194///
195/// - Subdirectories are skipped (not recursively processed)
196/// - Symbolic links are skipped
197/// - Empty directories return an empty rules vector
198/// - Files are processed in alphabetical order by filename
199/// - All successfully parsed rules are merged in order
200///
201/// # Examples
202///
203/// Loading a directory of magic files:
204///
205/// ```rust,no_run
206/// use libmagic_rs::parser::load_magic_directory;
207/// use std::path::Path;
208///
209/// let parsed = load_magic_directory(Path::new("/usr/share/file/magic.d"))?;
210/// println!("Loaded {} rules from directory", parsed.rules.len());
211/// # Ok::<(), libmagic_rs::ParseError>(())
212/// ```
213///
214/// Creating a Magdir-style directory structure:
215///
216/// ```rust,no_run
217/// use libmagic_rs::parser::load_magic_directory;
218/// use std::path::Path;
219///
220/// // Directory structure:
221/// // magic.d/
222/// //   ├── 01-elf
223/// //   ├── 02-archive
224/// //   └── 03-text
225///
226/// let parsed = load_magic_directory(Path::new("./magic.d"))?;
227/// // Rules from all three files are merged in alphabetical order
228/// # Ok::<(), libmagic_rs::ParseError>(())
229/// ```
230///
231/// # Errors
232///
233/// Returns `ParseError` if:
234/// - The directory does not exist or cannot be accessed
235/// - Directory entries cannot be read
236/// - A file cannot be read due to I/O errors
237/// - A file contains invalid UTF-8 encoding
238///
239/// # Panics
240///
241/// This function does not panic under normal operation.
242// Directory iteration + per-file parse + error aggregation runs slightly over
243// the 100-line lint; splitting this module is tracked in #391.
244#[allow(clippy::too_many_lines)]
245pub fn load_magic_directory(dir_path: &Path) -> Result<ParsedMagic, ParseError> {
246    use std::fs;
247
248    // Read directory entries
249    let entries = fs::read_dir(dir_path).map_err(|e| {
250        ParseError::invalid_syntax(
251            0,
252            format!("Failed to read directory '{}': {}", dir_path.display(), e),
253        )
254    })?;
255
256    // Collect and sort entries by filename for deterministic ordering
257    let mut file_paths: Vec<std::path::PathBuf> = Vec::new();
258    for entry in entries {
259        let entry = entry.map_err(|e| {
260            ParseError::invalid_syntax(
261                0,
262                format!(
263                    "Failed to read directory entry in '{}': {}",
264                    dir_path.display(),
265                    e
266                ),
267            )
268        })?;
269
270        let path = entry.path();
271        let file_type = entry.file_type().map_err(|e| {
272            ParseError::invalid_syntax(
273                0,
274                format!("Failed to read file type for '{}': {}", path.display(), e),
275            )
276        })?;
277
278        // Only process regular files, skip directories and symlinks.
279        // `is_file()` (not `!is_dir()`) is deliberate: sockets, FIFOs, and
280        // device nodes must also be excluded from magic-file discovery.
281        #[allow(clippy::filetype_is_file)]
282        if file_type.is_file() && !file_type.is_symlink() {
283            file_paths.push(path);
284        }
285    }
286
287    // Sort by filename for deterministic ordering
288    file_paths.sort_by_key(|path| path.file_name().map(std::ffi::OsStr::to_os_string));
289
290    // Accumulate rules and name tables from all files
291    let mut all_rules = Vec::new();
292    let mut merged_table = NameTable::empty();
293    let mut parse_failures: Vec<(PathBuf, ParseError)> = Vec::new();
294    // Files that parsed (line-tolerantly) but contributed no usable rule or
295    // name despite having non-empty content -- i.e. every rule was skipped as
296    // unparseable. Tracked so an all-unusable directory is still reported as a
297    // failure even though tolerant parsing returns Ok for each such file.
298    let mut empty_files: Vec<PathBuf> = Vec::new();
299    let mut any_success = false;
300    let file_count = file_paths.len();
301
302    for path in file_paths {
303        // Read file contents (size-bounded to prevent memory exhaustion)
304        let contents = match read_magic_file_bounded(&path) {
305            Ok(contents) => contents,
306            Err(e) => {
307                // I/O errors (including oversized files) are critical
308                return Err(ParseError::invalid_syntax(
309                    0,
310                    format!("Failed to read file '{}': {}", path.display(), e),
311                ));
312            }
313        };
314
315        // Parse the file (line-tolerant: unparseable rules are skipped with a
316        // warning rather than dropping the whole file).
317        match super::parse_text_magic_file_tolerant(&contents, Some(&path)) {
318            Ok(parsed) => {
319                if parsed.rules.is_empty() && parsed.name_table.is_empty() {
320                    // Contributed nothing usable. If the file had actual rule
321                    // lines (not just comments/blank lines), they were all
322                    // skipped as unparseable -- record it so an entirely-
323                    // unusable directory is still reported as a failure. A
324                    // genuinely empty or comment-only file is valid and is NOT
325                    // recorded (it simply contributes no rules).
326                    if has_rule_lines(&contents) {
327                        empty_files.push(path);
328                    }
329                } else {
330                    any_success = true;
331                    all_rules.extend(parsed.rules);
332                    merged_table.merge(parsed.name_table);
333                }
334            }
335            Err(e) => {
336                // Hard (preprocess-level) failure -- track for reporting.
337                parse_failures.push((path, e));
338            }
339        }
340    }
341
342    // A directory has loaded only if some file contributed a usable rule or
343    // name-table entry. `any_success` (not `all_rules.is_empty()`) so that a
344    // directory of pure `name`-subroutine files is not mistaken for failure.
345    // With line-tolerant parsing, a file whose rules were all skipped returns
346    // Ok with nothing; a directory where every content-bearing file is like
347    // that (or fails preprocessing) has loaded nothing and is reported as a
348    // failure -- preserving the pre-tolerance contract for a wholly-unusable
349    // directory.
350    if !any_success && (!parse_failures.is_empty() || !empty_files.is_empty()) {
351        use std::fmt::Write;
352
353        let mut problems: Vec<String> = parse_failures
354            .iter()
355            .map(|(path, e)| format!("  - {}: {}", path.display(), e))
356            .collect();
357        problems.extend(
358            empty_files
359                .iter()
360                .map(|path| format!("  - {}: no usable rules (all skipped)", path.display())),
361        );
362
363        let mut message = format!(
364            "All {file_count} magic file(s) in directory failed to parse or produced no usable rules"
365        );
366        let shown = problems.iter().take(3).cloned().collect::<Vec<_>>();
367        if !shown.is_empty() {
368            message.push_str(":\n");
369            message.push_str(&shown.join("\n"));
370            if problems.len() > 3 {
371                // fmt::Write to a String is infallible; discard the Result
372                // rather than unwrap so the no-panic policy holds regardless.
373                #[allow(clippy::let_underscore_must_use)]
374                let _ = write!(
375                    message,
376                    "\n  ... and {} more",
377                    problems.len().saturating_sub(3)
378                );
379            }
380        }
381
382        return Err(ParseError::invalid_syntax(0, message));
383    }
384
385    // Log warnings for partial failures (some files parsed, some failed)
386    for (path, e) in &parse_failures {
387        warn!("Failed to parse '{}': {}", path.display(), e);
388    }
389
390    Ok(ParsedMagic {
391        rules: all_rules,
392        name_table: merged_table,
393    })
394}
395
396/// Loads magic rules from a file or directory, automatically detecting the format.
397///
398/// This is the unified entry point for loading magic rules from the filesystem. It
399/// automatically detects whether the path points to a text magic file, a directory
400/// containing magic files, or a binary compiled magic file, and dispatches to the
401/// appropriate handler.
402///
403/// # Format Detection and Handling
404///
405/// The function uses [`detect_format()`] to determine the file type and handles each
406/// format as follows:
407///
408/// - **Text format**: Reads the file contents and parses using [`super::parse_text_magic_file()`]
409/// - **Directory format**: Loads all magic files from the directory using [`load_magic_directory()`]
410/// - **Binary format**: Returns an error with guidance to use the `--use-builtin` option
411///
412/// # Arguments
413///
414/// * `path` - Path to a magic file or directory. Can be absolute or relative.
415///
416/// # Returns
417///
418/// Returns `Ok(Vec<MagicRule>)` containing all successfully parsed magic rules. For
419/// directories, rules from all files are merged in alphabetical order by filename.
420///
421/// # Errors
422///
423/// This function returns a [`ParseError`] in the following cases:
424///
425/// - **File not found**: The specified path does not exist
426/// - **Unsupported format**: The file is a binary compiled magic file (`.mgc`)
427/// - **Parse errors**: The magic file contains syntax errors or invalid rules
428/// - **I/O errors**: File system errors during reading (permissions, disk errors, etc.)
429///
430/// # Examples
431///
432/// ## Loading a text magic file
433///
434/// ```no_run
435/// use libmagic_rs::parser::load_magic_file;
436/// use std::path::Path;
437///
438/// let parsed = load_magic_file(Path::new("/usr/share/misc/magic"))?;
439/// println!("Loaded {} magic rules", parsed.rules.len());
440/// # Ok::<(), libmagic_rs::ParseError>(())
441/// ```
442///
443/// ## Loading a directory of magic files
444///
445/// ```no_run
446/// use libmagic_rs::parser::load_magic_file;
447/// use std::path::Path;
448///
449/// let parsed = load_magic_file(Path::new("/usr/share/misc/magic.d"))?;
450/// println!("Loaded {} rules from directory", parsed.rules.len());
451/// # Ok::<(), libmagic_rs::ParseError>(())
452/// ```
453///
454/// ## Handling binary format errors
455///
456/// ```no_run
457/// use libmagic_rs::parser::load_magic_file;
458/// use std::path::Path;
459///
460/// match load_magic_file(Path::new("/usr/share/misc/magic.mgc")) {
461///     Ok(parsed) => println!("Loaded {} rules", parsed.rules.len()),
462///     Err(e) => {
463///         eprintln!("Error loading magic file: {}", e);
464///         eprintln!("Hint: Use --use-builtin for binary files");
465///     }
466/// }
467/// # Ok::<(), libmagic_rs::ParseError>(())
468/// ```
469///
470/// # Security
471///
472/// This function delegates to [`super::parse_text_magic_file()`] or [`load_magic_directory()`]
473/// based on format detection. Security considerations are handled by those functions:
474///
475/// - Rule hierarchy depth is bounded during parsing
476/// - Invalid syntax is rejected with descriptive errors
477/// - Binary `.mgc` files are rejected (not parsed)
478///
479/// A 1 GB size limit (`MAX_MAGIC_FILE_SIZE`, module-internal) is enforced on each file loaded
480/// (both standalone files and files within a directory) to prevent memory
481/// exhaustion from maliciously oversized inputs. Files exceeding the limit are
482/// rejected with a `ParseError` before their contents are read.
483///
484/// # See Also
485///
486/// - [`detect_format()`] - Format detection logic
487/// - [`super::parse_text_magic_file()`] - Text file parser
488/// - [`load_magic_directory()`] - Directory loader
489pub fn load_magic_file(path: &Path) -> Result<ParsedMagic, ParseError> {
490    // Detect the magic file format
491    let format = detect_format(path)?;
492
493    // Dispatch to appropriate handler based on format
494    match format {
495        MagicFileFormat::Text => {
496            // Read file contents (size-bounded) and parse as text magic file
497            let content = read_magic_file_bounded(path)?;
498            super::parse_text_magic_file_tolerant(&content, Some(path))
499        }
500        MagicFileFormat::Directory => {
501            // Load all magic files from directory
502            load_magic_directory(path)
503        }
504        MagicFileFormat::Binary => {
505            // Binary compiled magic files are not supported
506            Err(unsupported_binary_magic_error())
507        }
508    }
509}
510
511/// Loads text magic rules from a bounded reader.
512pub(crate) fn load_magic_reader<R: Read>(reader: R) -> Result<ParsedMagic, ParseError> {
513    let content = read_magic_reader_bounded(reader)?;
514    super::parse_text_magic_file_tolerant(&content, None)
515}
516
517/// Loads text magic rules from bounded, owned bytes.
518pub(crate) fn load_magic_bytes(bytes: Vec<u8>) -> Result<ParsedMagic, ParseError> {
519    let content = decode_magic_bytes_bounded(bytes)?;
520    super::parse_text_magic_file_tolerant(&content, None)
521}
522
523#[cfg(test)]
524mod tests {
525    // Restriction lints without an allow-*-in-tests config option;
526    // tests create exactly one level under a fresh TempDir.
527    #![allow(clippy::create_dir)]
528
529    use super::*;
530
531    #[test]
532    fn test_read_magic_reader_is_bounded() {
533        let content = read_magic_reader_with_limit(&b"1234"[..], 4)
534            .expect("reader input at the limit must be accepted");
535        assert_eq!(content, "1234");
536
537        let error = read_magic_reader_with_limit(&b"12345"[..], 4)
538            .expect_err("reader input above the limit must fail");
539
540        assert!(matches!(error, ParseError::InvalidSyntax { line: 0, .. }));
541        assert!(error.to_string().contains("more than 4 bytes"));
542    }
543
544    #[test]
545    fn test_decode_magic_bytes_is_bounded() {
546        let content = decode_magic_bytes_with_limit(b"1234".to_vec(), 4)
547            .expect("owned bytes at the limit must be accepted");
548        assert_eq!(content, "1234");
549
550        let oversized = decode_magic_bytes_with_limit(b"12345".to_vec(), 4)
551            .expect_err("owned bytes above the limit must fail");
552        assert!(matches!(
553            oversized,
554            ParseError::InvalidSyntax { line: 0, .. }
555        ));
556        assert!(oversized.to_string().contains("more than 4 bytes"));
557    }
558
559    // ============================================================
560    // Tests for load_magic_directory (6+ test cases)
561    // ============================================================
562
563    #[test]
564    fn test_load_directory_critical_error_io() {
565        use std::path::Path;
566
567        let non_existent = Path::new("/this/should/not/exist/anywhere/at/all");
568        let result = load_magic_directory(non_existent);
569
570        assert!(
571            result.is_err(),
572            "Should return error for non-existent directory"
573        );
574        let err = result.unwrap_err();
575        assert!(err.to_string().contains("Failed to read directory"));
576    }
577
578    #[test]
579    fn test_load_directory_non_critical_error_parse() {
580        use std::fs;
581        use tempfile::TempDir;
582
583        let temp_dir = TempDir::new().expect("Failed to create temp dir");
584
585        // Create a valid file
586        let valid_path = temp_dir.path().join("valid.magic");
587        fs::write(&valid_path, "0 string \\x01\\x02 valid\n").expect("Failed to write valid file");
588
589        // Create an invalid file
590        let invalid_path = temp_dir.path().join("invalid.magic");
591        fs::write(&invalid_path, "this is invalid syntax\n").expect("Failed to write invalid file");
592
593        // Should succeed, loading only the valid file
594        let parsed = load_magic_directory(temp_dir.path()).expect("Should load valid files");
595
596        assert_eq!(parsed.rules.len(), 1, "Should load only valid file");
597        assert_eq!(parsed.rules[0].message, "valid");
598    }
599
600    #[test]
601    fn test_load_directory_empty_files() {
602        use std::fs;
603        use tempfile::TempDir;
604
605        let temp_dir = TempDir::new().expect("Failed to create temp dir");
606
607        // Create an empty file
608        let empty_path = temp_dir.path().join("empty.magic");
609        fs::write(&empty_path, "").expect("Failed to write empty file");
610
611        // Create a file with only comments
612        let comments_path = temp_dir.path().join("comments.magic");
613        fs::write(&comments_path, "# Just comments\n# Nothing else\n")
614            .expect("Failed to write comments file");
615
616        // Should succeed with no rules
617        let parsed = load_magic_directory(temp_dir.path()).expect("Should handle empty files");
618
619        assert_eq!(
620            parsed.rules.len(),
621            0,
622            "Empty files should contribute no rules"
623        );
624    }
625
626    #[test]
627    fn test_load_directory_all_content_bearing_but_all_rules_skipped_errors() {
628        use std::fs;
629        use tempfile::TempDir;
630
631        let temp_dir = TempDir::new().expect("Failed to create temp dir");
632
633        // Two files that BOTH have content (non-comment rule lines) but whose
634        // every rule is unparseable. Under line-tolerant parsing (GOTCHAS S3.11)
635        // each file returns Ok with zero rules, but the directory contributed
636        // nothing usable, so `load_magic_directory` must still Err -- preserving
637        // the pre-tolerance "all failed" contract via the `empty_files` /
638        // `has_rule_lines` tracking. This is distinct from a directory of
639        // genuinely-empty/comment-only files (test_load_directory_empty_files),
640        // which is a valid no-op success, and from the mixed valid+invalid case
641        // (test_load_directory_non_critical_error_parse), which succeeds because
642        // one file contributed a rule.
643        fs::write(
644            temp_dir.path().join("bad1.magic"),
645            "notanoffset badtype whatever\nalso not a valid rule line\n",
646        )
647        .expect("Failed to write bad1");
648        fs::write(
649            temp_dir.path().join("bad2.magic"),
650            "still invalid syntax here\n",
651        )
652        .expect("Failed to write bad2");
653
654        let err = load_magic_directory(temp_dir.path()).expect_err(
655            "a directory whose content-bearing files all parse to zero rules must fail",
656        );
657        let msg = err.to_string();
658        assert!(
659            msg.contains("failed to parse"),
660            "error must report the all-failed contract: {msg}"
661        );
662        assert!(
663            msg.contains("no usable rules (all skipped)"),
664            "error must attribute the content-bearing-but-all-skipped files: {msg}"
665        );
666    }
667
668    #[test]
669    fn test_load_directory_binary_files() {
670        use std::fs;
671        use tempfile::TempDir;
672
673        let temp_dir = TempDir::new().expect("Failed to create temp dir");
674
675        // Create a binary file (invalid UTF-8). Lossy conversion turns this
676        // into U+FFFD characters that the grammar parser cannot interpret as
677        // a rule; the directory loader treats that as a non-critical parse
678        // failure and skips the file.
679        let binary_path = temp_dir.path().join("binary.dat");
680        fs::write(&binary_path, [0xFF, 0xFE, 0xFF, 0xFE]).expect("Failed to write binary file");
681
682        // Create a valid text file
683        let valid_path = temp_dir.path().join("valid.magic");
684        fs::write(&valid_path, "0 string \\x01\\x02 valid\n").expect("Failed to write valid file");
685
686        let parsed = load_magic_directory(temp_dir.path())
687            .expect("Directory with a binary file alongside a valid file should still load");
688
689        assert_eq!(
690            parsed.rules.len(),
691            1,
692            "Only the valid magic file should contribute rules"
693        );
694        assert_eq!(parsed.rules[0].message, "valid");
695    }
696
697    #[test]
698    fn test_load_directory_mixed_extensions() {
699        use std::fs;
700        use tempfile::TempDir;
701
702        let temp_dir = TempDir::new().expect("Failed to create temp dir");
703
704        // Create files with different extensions
705        fs::write(
706            temp_dir.path().join("file.magic"),
707            "0 string \\x01\\x02 magic\n",
708        )
709        .expect("Failed to write .magic file");
710        fs::write(
711            temp_dir.path().join("file.txt"),
712            "0 string \\x03\\x04 txt\n",
713        )
714        .expect("Failed to write .txt file");
715        fs::write(temp_dir.path().join("noext"), "0 string \\x05\\x06 noext\n")
716            .expect("Failed to write no-ext file");
717
718        let parsed = load_magic_directory(temp_dir.path())
719            .expect("Should load all files regardless of extension");
720
721        assert_eq!(
722            parsed.rules.len(),
723            3,
724            "Should process all files regardless of extension"
725        );
726
727        let messages: Vec<&str> = parsed.rules.iter().map(|r| r.message.as_str()).collect();
728        assert!(messages.contains(&"magic"));
729        assert!(messages.contains(&"txt"));
730        assert!(messages.contains(&"noext"));
731    }
732
733    #[test]
734    fn test_load_directory_alphabetical_ordering() {
735        use std::fs;
736        use tempfile::TempDir;
737
738        let temp_dir = TempDir::new().expect("Failed to create temp dir");
739
740        // Create files in non-alphabetical order - using valid magic syntax with hex escapes
741        fs::write(
742            temp_dir.path().join("03-third"),
743            "0 string \\x07\\x08\\x09 third\n",
744        )
745        .expect("Failed to write third file");
746        fs::write(
747            temp_dir.path().join("01-first"),
748            "0 string \\x01\\x02\\x03 first\n",
749        )
750        .expect("Failed to write first file");
751        fs::write(
752            temp_dir.path().join("02-second"),
753            "0 string \\x04\\x05\\x06 second\n",
754        )
755        .expect("Failed to write second file");
756
757        let parsed = load_magic_directory(temp_dir.path()).expect("Should load directory in order");
758
759        assert_eq!(parsed.rules.len(), 3);
760        // Should be sorted alphabetically by filename
761        assert_eq!(parsed.rules[0].message, "first");
762        assert_eq!(parsed.rules[1].message, "second");
763        assert_eq!(parsed.rules[2].message, "third");
764    }
765
766    // ============================================================
767    // Tests for load_magic_file (5+ test cases)
768    // ============================================================
769
770    #[test]
771    fn test_load_magic_file_text_format() {
772        use std::fs;
773        use tempfile::TempDir;
774
775        let temp_dir = TempDir::new().expect("Failed to create temp dir");
776        let magic_file = temp_dir.path().join("magic.txt");
777
778        // Create text magic file with valid content
779        fs::write(&magic_file, "0 string \\x7fELF ELF executable\n")
780            .expect("Failed to write magic file");
781
782        // Load using load_magic_file
783        let parsed = load_magic_file(&magic_file).expect("Failed to load text magic file");
784
785        assert_eq!(parsed.rules.len(), 1);
786        assert_eq!(parsed.rules[0].message, "ELF executable");
787    }
788
789    #[test]
790    fn test_load_magic_file_directory_format() {
791        use std::fs;
792        use tempfile::TempDir;
793
794        let temp_dir = TempDir::new().expect("Failed to create temp dir");
795        let magic_dir = temp_dir.path().join("magic.d");
796        fs::create_dir(&magic_dir).expect("Failed to create magic directory");
797
798        // Create multiple files in directory
799        fs::write(
800            magic_dir.join("00_elf"),
801            "0 string \\x7fELF ELF executable\n",
802        )
803        .expect("Failed to write elf file");
804        fs::write(
805            magic_dir.join("01_zip"),
806            "0 string \\x50\\x4b\\x03\\x04 ZIP archive\n",
807        )
808        .expect("Failed to write zip file");
809
810        // Load using load_magic_file
811        let parsed = load_magic_file(&magic_dir).expect("Failed to load directory");
812
813        assert_eq!(parsed.rules.len(), 2);
814        assert_eq!(parsed.rules[0].message, "ELF executable");
815        assert_eq!(parsed.rules[1].message, "ZIP archive");
816    }
817
818    #[test]
819    fn test_load_magic_file_binary_format_error() {
820        use std::fs::File;
821        use std::io::Write;
822        use tempfile::TempDir;
823
824        let temp_dir = TempDir::new().expect("Failed to create temp dir");
825        let binary_file = temp_dir.path().join("magic.mgc");
826
827        // Create binary file with .mgc magic number
828        let mut file = File::create(&binary_file).expect("Failed to create binary file");
829        let magic_number: [u8; 4] = [0x1C, 0x04, 0x1E, 0xF1]; // Little-endian 0xF11E041C
830        file.write_all(&magic_number)
831            .expect("Failed to write magic number");
832
833        // Attempt to load binary file
834        let result = load_magic_file(&binary_file);
835
836        assert!(result.is_err(), "Should fail to load binary .mgc file");
837
838        let error = result.unwrap_err();
839        let error_msg = error.to_string();
840
841        // Verify error mentions unsupported format and --use-builtin
842        assert!(
843            error_msg.contains("Binary") || error_msg.contains("binary"),
844            "Error should mention binary format: {error_msg}",
845        );
846        assert!(
847            error_msg.contains("--use-builtin") || error_msg.contains("built-in"),
848            "Error should mention --use-builtin option: {error_msg}",
849        );
850    }
851
852    #[test]
853    fn test_load_magic_file_io_error() {
854        use std::path::Path;
855
856        // Try to load non-existent file
857        let non_existent = Path::new("/this/path/should/not/exist/magic.txt");
858        let result = load_magic_file(non_existent);
859
860        assert!(result.is_err(), "Should fail for non-existent file");
861    }
862
863    #[test]
864    fn test_load_magic_file_tolerates_unparseable_rule_and_keeps_valid_ones() {
865        use std::fs;
866        use tempfile::TempDir;
867
868        let temp_dir = TempDir::new().expect("Failed to create temp dir");
869        let mixed_file = temp_dir.path().join("mixed.magic");
870
871        // A valid rule, an unparseable rule (missing offset), then another valid
872        // rule. Runtime loading is line-tolerant (GNU `file` semantics, GOTCHAS
873        // S3.11): the bad rule is skipped with a warning and the valid rules on
874        // either side survive, instead of the whole file being dropped. (This
875        // is what lets real system magic files keep their common-format
876        // detection despite a stray construct this parser cannot yet handle.)
877        fs::write(
878            &mixed_file,
879            "0 string GOOD1 first good rule\nstring test invalid\n0 string GOOD2 second good rule\n",
880        )
881        .expect("Failed to write file");
882
883        let parsed = load_magic_file(&mixed_file)
884            .expect("runtime load must tolerate an unparseable rule, not abort the whole file");
885        let msgs: Vec<&str> = parsed.rules.iter().map(|r| r.message.as_str()).collect();
886        assert!(
887            msgs.contains(&"first good rule"),
888            "a valid rule before the bad one must survive: {msgs:?}"
889        );
890        assert!(
891            msgs.contains(&"second good rule"),
892            "a valid rule after the bad one must survive: {msgs:?}"
893        );
894        assert_eq!(
895            parsed.rules.len(),
896            2,
897            "the unparseable rule must be dropped, keeping exactly the two valid ones: {msgs:?}"
898        );
899    }
900
901    #[test]
902    fn test_tolerant_skip_warning_includes_source_file_path() {
903        use std::fs;
904        use tempfile::TempDir;
905
906        // The skipped-rule warning must carry the source file path so that a
907        // directory load spanning many files can locate the offending rule
908        // (issue #391 item 3). The loader threads the path through
909        // `parse_text_magic_file_tolerant` -> `build_rule_hierarchy_tolerant`.
910        let temp_dir = TempDir::new().expect("Failed to create temp dir");
911        let bad_file = temp_dir.path().join("has_bad_rule.magic");
912        fs::write(&bad_file, "0 string GOOD good rule\nstring test invalid\n")
913            .expect("Failed to write file");
914
915        testing_logger::setup();
916        let _ = load_magic_file(&bad_file).expect("tolerant load must not abort");
917        let path_str = bad_file.display().to_string();
918        testing_logger::validate(|captured_logs| {
919            let skip_warns: Vec<_> = captured_logs
920                .iter()
921                .filter(|l| l.body.contains("skipping unparseable magic rule"))
922                .collect();
923            assert_eq!(
924                skip_warns.len(),
925                1,
926                "expected exactly one skip warning, got: {:?}",
927                captured_logs.iter().map(|l| &l.body).collect::<Vec<_>>()
928            );
929            assert_eq!(skip_warns[0].level, log::Level::Warn);
930            assert!(
931                skip_warns[0].body.contains(&path_str),
932                "skip warning must include the source file path '{path_str}', got: {}",
933                skip_warns[0].body
934            );
935        });
936    }
937
938    #[test]
939    fn test_tolerant_skip_warning_omits_path_clause_when_source_is_none() {
940        // The direct-API tolerant path (no source file) must still warn on an
941        // unparseable rule but WITHOUT a " in <path>" clause -- the None arm of
942        // the source label (issue #391 item 3). Loader call sites always pass
943        // Some(path); this pins the None branch that a direct caller hits.
944        testing_logger::setup();
945        let ParsedMagic { rules, .. } = super::super::parse_text_magic_file_tolerant(
946            "0 string GOOD good\nstring test bad\n",
947            None,
948        )
949        .expect("tolerant parse must not abort");
950        assert_eq!(
951            rules.len(),
952            1,
953            "the good rule survives, the bad one is dropped"
954        );
955        testing_logger::validate(|captured_logs| {
956            let skip_warns: Vec<_> = captured_logs
957                .iter()
958                .filter(|l| l.body.contains("skipping unparseable magic rule"))
959                .collect();
960            assert_eq!(skip_warns.len(), 1);
961            // Target the exact source-path clause (`... magic rule in <path> at
962            // line ...`), not any " in " substring -- a rule preview or parse
963            // error could legitimately contain " in " without a path clause.
964            assert!(
965                !skip_warns[0].body.contains("magic rule in "),
966                "with source=None the warning must carry no ' in <path>' clause, got: {}",
967                skip_warns[0].body
968            );
969        });
970    }
971
972    #[test]
973    fn test_load_magic_file_drops_subtree_of_unparseable_rule_without_reattaching() {
974        use std::fs;
975        use tempfile::TempDir;
976
977        let temp_dir = TempDir::new().expect("Failed to create temp dir");
978        let file = temp_dir.path().join("subtree.magic");
979
980        // A valid parent, then an UNPARSEABLE rule (no valid offset) that owns a
981        // `>`-indented child, then a valid sibling back at the original level.
982        // GOTCHAS S3.11: when the bad rule is skipped, its deeper-indented
983        // subtree must be dropped WITH it (`skip_subtree_deeper_than` in
984        // `hierarchy.rs`). The orphaned child must NOT silently re-attach to the
985        // previous level-0 rule (`GOOD1`) nor survive as a stray top-level rule,
986        // and the trailing `GOOD2` proves the skip threshold resets so a later
987        // sibling at the original indent still parses.
988        fs::write(
989            &file,
990            "0 string GOOD1 parent rule\n\
991             notanoffset badtype orphan parent\n\
992             >0 byte x orphaned child that must be dropped\n\
993             0 string GOOD2 sibling after the dropped subtree\n",
994        )
995        .expect("Failed to write file");
996
997        let parsed = load_magic_file(&file)
998            .expect("runtime load must tolerate the unparseable rule and its subtree");
999
1000        let top_msgs: Vec<&str> = parsed.rules.iter().map(|r| r.message.as_str()).collect();
1001        assert_eq!(
1002            parsed.rules.len(),
1003            2,
1004            "exactly the two valid top-level rules survive: {top_msgs:?}"
1005        );
1006        assert!(
1007            top_msgs.contains(&"parent rule"),
1008            "the valid rule before the bad one must survive: {top_msgs:?}"
1009        );
1010        assert!(
1011            top_msgs.contains(&"sibling after the dropped subtree"),
1012            "the sibling after the dropped subtree must parse (threshold reset): {top_msgs:?}"
1013        );
1014
1015        // The orphaned child must appear NOWHERE: not re-attached to the
1016        // preceding level-0 rule, and not as any surviving rule's child.
1017        let good1 = parsed
1018            .rules
1019            .iter()
1020            .find(|r| r.message == "parent rule")
1021            .expect("GOOD1 must be present");
1022        assert!(
1023            good1.children.is_empty(),
1024            "the dropped child must not re-attach to the previous level-0 rule: {:?}",
1025            good1
1026                .children
1027                .iter()
1028                .map(|c| c.message.as_str())
1029                .collect::<Vec<_>>()
1030        );
1031        let orphan_reattached = parsed
1032            .rules
1033            .iter()
1034            .flat_map(|r| r.children.iter())
1035            .any(|c| c.message.contains("orphaned child"));
1036        assert!(
1037            !orphan_reattached,
1038            "the orphaned child of the unparseable rule must be dropped entirely"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_max_magic_file_size_matches_file_buffer_limit() {
1044        // Ensure the duplicated limit stays in sync with FileBuffer::MAX_FILE_SIZE.
1045        // loader.rs cannot `use crate::io::FileBuffer` at module scope because
1046        // build.rs pulls this file in via `#[path]`, but tests compile as part
1047        // of the library and can reach it fine.
1048        assert_eq!(
1049            MAX_MAGIC_FILE_SIZE,
1050            crate::io::FileBuffer::MAX_FILE_SIZE,
1051            "MAX_MAGIC_FILE_SIZE must match FileBuffer::MAX_FILE_SIZE"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_load_magic_file_rejects_oversized_file() {
1057        use std::fs::File;
1058        use tempfile::TempDir;
1059
1060        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1061        let oversized = temp_dir.path().join("huge.magic");
1062
1063        // Create a sparse file whose reported size exceeds MAX_MAGIC_FILE_SIZE
1064        // without actually consuming that much disk space.
1065        let file = File::create(&oversized).expect("Failed to create oversized file");
1066        file.set_len(MAX_MAGIC_FILE_SIZE + 1)
1067            .expect("Failed to set sparse file length");
1068        drop(file);
1069
1070        let result = load_magic_file(&oversized);
1071
1072        assert!(
1073            result.is_err(),
1074            "Loading a file larger than MAX_MAGIC_FILE_SIZE must fail"
1075        );
1076
1077        let err_msg = result.unwrap_err().to_string();
1078        assert!(
1079            err_msg.contains("too large"),
1080            "Error should indicate size limit violation, got: {err_msg}"
1081        );
1082        assert!(
1083            err_msg.contains(&MAX_MAGIC_FILE_SIZE.to_string()),
1084            "Error should mention the maximum allowed size, got: {err_msg}"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_load_magic_file_tolerates_non_utf8_in_comment() {
1090        // Regression: /usr/share/file/magic/filesystems on macOS contains a
1091        // Latin-1 `ß` (0xdf) in a contributor attribution comment. Previously
1092        // this was rejected by `fs::read_to_string` with an opaque "stream
1093        // did not contain valid UTF-8" error. The loader must now tolerate
1094        // non-UTF-8 bytes in comments (and anywhere else they appear) by
1095        // lossily replacing them.
1096        use std::fs;
1097        use tempfile::TempDir;
1098
1099        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1100        let magic_path = temp_dir.path().join("with-latin1-comment.magic");
1101
1102        let mut bytes: Vec<u8> = Vec::new();
1103        bytes.extend_from_slice(b"# From: Thomas Wei");
1104        bytes.push(0xdf); // invalid UTF-8 (Latin-1 encoding of `ß`)
1105        bytes.extend_from_slice(b"schuh <thomas@example.invalid>\n");
1106        bytes.extend_from_slice(b"0 string \\x7fELF ELF executable\n");
1107        fs::write(&magic_path, &bytes).expect("Failed to write magic file with non-UTF-8 byte");
1108
1109        let parsed = load_magic_file(&magic_path)
1110            .expect("Magic file with non-UTF-8 bytes in a comment must still load");
1111
1112        assert_eq!(
1113            parsed.rules.len(),
1114            1,
1115            "The ELF rule should be parsed; the comment is stripped"
1116        );
1117        assert_eq!(parsed.rules[0].message, "ELF executable");
1118    }
1119
1120    #[test]
1121    fn test_load_directory_merges_name_tables() {
1122        use std::fs;
1123        use tempfile::TempDir;
1124
1125        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1126
1127        // Each file defines a different named subroutine.
1128        fs::write(
1129            temp_dir.path().join("00_first"),
1130            "0 name sub_a\n>0 byte 1 a-body\n",
1131        )
1132        .expect("Failed to write sub_a file");
1133        fs::write(
1134            temp_dir.path().join("01_second"),
1135            "0 name sub_b\n>0 byte 2 b-body\n",
1136        )
1137        .expect("Failed to write sub_b file");
1138
1139        let parsed =
1140            load_magic_directory(temp_dir.path()).expect("Should load both name subroutines");
1141
1142        // Both `name` rules are hoisted out, so top-level rules list is empty.
1143        assert_eq!(parsed.rules.len(), 0);
1144        assert!(parsed.name_table.get("sub_a").is_some());
1145        assert!(parsed.name_table.get("sub_b").is_some());
1146    }
1147}