Skip to main content

libmagic_rs/output/
format.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Printf-style format specifier substitution for magic rule messages.
5//!
6//! Magic file messages frequently contain C-style format specifiers such as
7//! `%lld`, `%02x`, or `%s` that reference the rule's read value. GNU `file`
8//! renders the message with the value substituted at the specifier's
9//! position; without this pass libmagic-rs would emit the literal
10//! specifier tokens (e.g., `at_offset %lld`) and diverge visibly from
11//! `file(1)` output.
12//!
13//! The substitution is intentionally narrow: it supports the subset of
14//! C's `printf` syntax that appears in shipping magic corpora (notably
15//! `third_party/tests/searchbug.magic` and the GNU `file` `Magdir`
16//! collection). Unrecognized specifiers pass through literally with a
17//! `debug!` log rather than erroring -- matching the evaluator's
18//! graceful-skip discipline.
19//!
20//! Width masking for hex specifiers uses [`crate::parser::ast::TypeKind::bit_width`]
21//! so that e.g. a signed byte rendered with `%02x` produces the unsigned
22//! 8-bit interpretation (`0xff`, not `0xffffffffffffffff`).
23//!
24//! See the project plan at
25//! `docs/plans/2026-04-22-001-feat-meta-type-offset-and-format-substitution-plan.md`
26//! for scope, and GOTCHAS.md S14.2 for historical context.
27
28use log::debug;
29
30use crate::parser::ast::{TypeKind, Value};
31
32/// Substitute printf-style format specifiers in a magic rule message.
33///
34/// Walks `template` left to right. Plain text is copied verbatim; on
35/// each `%`, the full specifier (`%[flags][width][.precision][length]<conv>`)
36/// is parsed and substituted from `value`. `%%` emits a single `%`.
37/// Unrecognized or malformed specifiers are passed through literally
38/// with a `debug!` log.
39///
40/// `type_kind` is consulted only for hex specifiers, which need the
41/// natural bit width of the underlying read to mask sign-extended
42/// values correctly. For non-hex specifiers `type_kind` is ignored.
43///
44/// # Examples
45///
46/// ```
47/// use libmagic_rs::output::format::format_magic_message;
48/// use libmagic_rs::parser::ast::{TypeKind, Value};
49///
50/// let out = format_magic_message(
51///     "at_offset %lld",
52///     &Value::Uint(11),
53///     &TypeKind::Byte { signed: false },
54/// );
55/// assert_eq!(out, "at_offset 11");
56///
57/// let out = format_magic_message(
58///     "followed_by 0x%02x",
59///     &Value::Uint(0x31),
60///     &TypeKind::Byte { signed: false },
61/// );
62/// assert_eq!(out, "followed_by 0x31");
63///
64/// // Unknown specifier falls through literally.
65/// let out = format_magic_message("%q", &Value::Uint(0), &TypeKind::Byte { signed: false });
66/// assert_eq!(out, "%q");
67///
68/// // `%%` is an escaped literal percent.
69/// let out = format_magic_message("100%% sure", &Value::Uint(0), &TypeKind::Byte { signed: false });
70/// assert_eq!(out, "100% sure");
71/// ```
72#[must_use]
73// Indexing is invariant-safe: every `bytes[i]` is guarded by an
74// `i < bytes.len()` loop condition.
75#[allow(clippy::indexing_slicing)]
76pub fn format_magic_message(template: &str, value: &Value, type_kind: &TypeKind) -> String {
77    let mut out = String::with_capacity(template.len());
78    let bytes = template.as_bytes();
79    let mut i = 0;
80    // Start of the most recent run of non-`%` bytes. We copy the run
81    // as a string slice rather than byte-by-byte so non-ASCII UTF-8
82    // code points survive intact. Scanning still happens at the byte
83    // level (safe because `%` is ASCII 0x25 and cannot appear as a
84    // UTF-8 continuation byte, which is always >= 0x80).
85    let mut plain_start = 0;
86
87    while i < bytes.len() {
88        if bytes[i] != b'%' {
89            i += 1;
90            continue;
91        }
92
93        // Flush any pending plain-text run as a single UTF-8 slice.
94        if plain_start < i {
95            out.push_str(&template[plain_start..i]);
96        }
97
98        // Start of a format specifier at position i.
99        let spec_start = i;
100        let Some(parsed_spec) = parse_spec(bytes, i + 1) else {
101            // Malformed specifier (e.g., trailing `%` with nothing after,
102            // or a sequence that doesn't end in a valid conversion char).
103            // Pass through the remaining literal and stop scanning.
104            debug!(
105                "format_magic_message: malformed specifier at byte {i} in template {template:?}; passing through remainder literally",
106            );
107            out.push_str(&template[i..]);
108            // Skip the trailing flush -- we have already emitted the
109            // remainder above.
110            plain_start = bytes.len();
111            break;
112        };
113        let next_i = parsed_spec.end;
114        if let Some(rendered) = render(&parsed_spec, value, type_kind) {
115            out.push_str(&rendered);
116        } else {
117            // Type mismatch or unsupported conversion; pass through the
118            // literal specifier and log.
119            let literal = &template[spec_start..next_i];
120            debug!(
121                "format_magic_message: unsupported specifier {literal:?} for value {value:?}; passing through literally",
122            );
123            out.push_str(literal);
124        }
125        i = next_i;
126        plain_start = i;
127    }
128
129    // Flush any trailing plain-text run.
130    if plain_start < bytes.len() {
131        out.push_str(&template[plain_start..]);
132    }
133
134    out
135}
136
137/// Kinds of conversion characters we recognize.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Conv {
140    /// `%d`, `%i`, `%ld`, `%lld` -- signed decimal.
141    Signed,
142    /// `%u`, `%lu`, `%llu` -- unsigned decimal.
143    Unsigned,
144    /// `%x` -- lowercase hex.
145    HexLower,
146    /// `%X` -- uppercase hex.
147    HexUpper,
148    /// `%o` -- octal.
149    Octal,
150    /// `%s` -- string.
151    Str,
152    /// `%c` -- single character (full 0x00-0xff byte range via Latin-1 code points).
153    Char,
154    /// `%%` -- literal percent.
155    Percent,
156}
157
158/// Parsed format specifier.
159#[derive(Debug, Clone)]
160struct Spec {
161    zero_pad: bool,
162    left_align: bool,
163    alt_form: bool,
164    width: usize,
165    /// `.<digits>` precision. Currently honored only for `%s` (`Conv::Str`),
166    /// where it truncates the rendered string to at most `precision`
167    /// characters -- e.g. sgml's `%.3s` renders the XML version `1.0` from a
168    /// longer field. `None` means no precision was given (no truncation).
169    precision: Option<usize>,
170    conv: Conv,
171    /// Byte index of the character *after* this specifier in the template.
172    end: usize,
173}
174
175/// Maximum width value accepted from a format specifier.
176///
177/// Caps the field width to prevent crafted magic rules with enormous widths
178/// (e.g., `%999999999d`) from driving unbounded `repeat_n` allocations in the
179/// padding helpers. 4096 is generous for any real magic-corpus usage.
180const MAX_FORMAT_WIDTH: usize = 4096;
181
182/// Parse a format specifier starting at `start` (the first byte after the
183/// leading `%`). Returns `None` if the sequence does not end in a
184/// recognized conversion character.
185// Indexing is invariant-safe: every `bytes[i]` is guarded by an
186// `i < bytes.len()` loop or branch condition.
187#[allow(clippy::indexing_slicing)]
188fn parse_spec(bytes: &[u8], start: usize) -> Option<Spec> {
189    let mut i = start;
190    let mut zero_pad = false;
191    let mut left_align = false;
192    let mut alt_form = false;
193
194    // Flags (subset: 0, -, #). Other flags (+, space) are parsed but ignored.
195    while i < bytes.len() {
196        match bytes[i] {
197            b'0' => {
198                zero_pad = true;
199                i += 1;
200            }
201            b'-' => {
202                left_align = true;
203                i += 1;
204            }
205            b'#' => {
206                alt_form = true;
207                i += 1;
208            }
209            b'+' | b' ' => {
210                // Accepted for syntactic completeness, no rendering effect
211                // in the current subset.
212                i += 1;
213            }
214            _ => break,
215        }
216    }
217
218    // Width (decimal digits). Capped at MAX_FORMAT_WIDTH to prevent
219    // unbounded allocations from crafted format strings.
220    let mut width: usize = 0;
221    while i < bytes.len() && bytes[i].is_ascii_digit() {
222        let digit = (bytes[i] - b'0') as usize;
223        width = width
224            .saturating_mul(10)
225            .saturating_add(digit)
226            .min(MAX_FORMAT_WIDTH);
227        i += 1;
228    }
229
230    // Precision (`.<digits>`). Captured for `%s` truncation (see `render`);
231    // numeric rendering is still whole-value and ignores it. A bare `.` with
232    // no digits means precision 0 (C semantics). The digit run is capped at
233    // MAX_FORMAT_WIDTH for the same crafted-input protection as `width`.
234    let mut precision: Option<usize> = None;
235    if i < bytes.len() && bytes[i] == b'.' {
236        i += 1;
237        let mut prec: usize = 0;
238        while i < bytes.len() && bytes[i].is_ascii_digit() {
239            let digit = (bytes[i] - b'0') as usize;
240            prec = prec
241                .saturating_mul(10)
242                .saturating_add(digit)
243                .min(MAX_FORMAT_WIDTH);
244            i += 1;
245        }
246        precision = Some(prec);
247    }
248
249    // Length modifier (`h`, `hh`, `l`, `ll`, `j`, `z`, `t`). We consume
250    // these for syntactic completeness but never rely on them -- all
251    // numeric rendering uses full u64/i64 width.
252    while i < bytes.len() {
253        match bytes[i] {
254            b'l' | b'h' | b'j' | b'z' | b't' => i += 1,
255            _ => break,
256        }
257    }
258
259    if i >= bytes.len() {
260        return None;
261    }
262
263    let conv = match bytes[i] {
264        b'd' | b'i' => Conv::Signed,
265        b'u' => Conv::Unsigned,
266        b'x' => Conv::HexLower,
267        b'X' => Conv::HexUpper,
268        b'o' => Conv::Octal,
269        b's' => Conv::Str,
270        b'c' => Conv::Char,
271        b'%' => Conv::Percent,
272        _ => return None,
273    };
274    i += 1;
275
276    Some(Spec {
277        zero_pad,
278        left_align,
279        alt_form,
280        width,
281        precision,
282        conv,
283        end: i,
284    })
285}
286
287/// Render the specifier against `value`, or return `None` if the value
288/// is type-incompatible with the conversion.
289fn render(spec: &Spec, value: &Value, type_kind: &TypeKind) -> Option<String> {
290    match spec.conv {
291        Conv::Percent => Some("%".to_string()),
292        Conv::Str => Some(render_str_spec(spec, value)),
293        Conv::Signed => {
294            let n = coerce_to_i64(value)?;
295            Some(pad_numeric(&n.to_string(), spec))
296        }
297        Conv::Unsigned => {
298            let n = coerce_to_u64(value)?;
299            Some(pad_numeric(&n.to_string(), spec))
300        }
301        Conv::HexLower => {
302            let n = coerce_to_u64_masked(value, type_kind)?;
303            // C printf suppresses the `0x`/`0X` alt-form prefix when the
304            // value is zero: `printf("%#x", 0)` emits `"0"`, not `"0x0"`.
305            let prefix = if spec.alt_form && n != 0 { "0x" } else { "" };
306            Some(render_prefixed_int(&format!("{n:x}"), prefix, spec))
307        }
308        Conv::HexUpper => {
309            let n = coerce_to_u64_masked(value, type_kind)?;
310            let prefix = if spec.alt_form && n != 0 { "0X" } else { "" };
311            Some(render_prefixed_int(&format!("{n:X}"), prefix, spec))
312        }
313        Conv::Octal => {
314            let n = coerce_to_u64_masked(value, type_kind)?;
315            // C printf uses a single "0" prefix for %#o (not Rust's "0o"),
316            // and suppresses the prefix when the value itself is zero --
317            // the resulting digit `0` already satisfies the "starts with
318            // 0" invariant that the alt-form is meant to guarantee.
319            let prefix = if spec.alt_form && n != 0 { "0" } else { "" };
320            Some(render_prefixed_int(&format!("{n:o}"), prefix, spec))
321        }
322        Conv::Char => {
323            let n = coerce_to_u64(value)?;
324            let byte = u8::try_from(n).ok()?;
325            // GNU `file` / C printf `%c` converts the int argument to
326            // unsigned char and emits it directly for all byte values
327            // 0x00-0xff. Rust's `String` must be valid UTF-8, so we
328            // embed bytes >= 0x80 as their Latin-1 code points (U+0080
329            // through U+00FF) via `char::from(u8)` which is infallible
330            // and lossless. Consumers with UTF-8 terminals see the
331            // 2-byte UTF-8 encoding of that code point; consumers
332            // iterating the returned bytes directly can recover the
333            // original byte by re-encoding the code point as Latin-1.
334            //
335            // POSIX: the `0` flag is ignored for `%c` -- zero-padding only
336            // applies to numeric/float conversions. Always use space-padding
337            // for `%c`, matching C printf behavior.
338            Some(pad_non_numeric(&char::from(byte).to_string(), spec))
339        }
340    }
341}
342
343/// Render a `%s` specifier: base string, then optional `.<precision>`
344/// truncation, then width padding.
345///
346/// Precision truncates to at most `precision` characters. Truncation is
347/// **char-wise**, not byte-wise: C's `%.Ns` truncates by bytes, but our value
348/// is a Rust `String` and a byte-wise cut could split a multi-byte UTF-8
349/// sequence. For the ASCII version/name fields that use precision in the magic
350/// corpus (`%.3s`, `%-.4s`, `%.10s`) the two are identical; char-wise is the
351/// safe choice for the rare non-ASCII case.
352///
353/// Width padding (via [`pad_non_numeric`]) is applied after truncation so
354/// `%4.4s` and `%-.4s` render correctly -- previously `%s` dropped width
355/// entirely.
356fn render_str_spec(spec: &Spec, value: &Value) -> String {
357    let base = render_string(value);
358    // Truncate to `p` chars in a single pass bounded by `p` (no preceding full
359    // `chars().count()` pass, and no byte slicing -- the repo forbids `&s[n..]`
360    // for UTF-8 safety). `take(p)` stops after at most `p` chars regardless of
361    // string length; when `p >= len` this collects an identical copy, which is
362    // a cheap, correct no-op for the rare precision case.
363    let truncated = match spec.precision {
364        Some(p) => base.chars().take(p).collect::<String>(),
365        None => base,
366    };
367    pad_non_numeric(&truncated, spec)
368}
369
370/// Render a [`Value`] for `%s`. Strings pass through; byte sequences are
371/// converted via lossy UTF-8; numbers render as decimal (GNU `file` does
372/// the same for mixed-type `%s` substitutions).
373fn render_string(value: &Value) -> String {
374    match value {
375        Value::String(s) => s.clone(),
376        Value::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
377        Value::Uint(n) => n.to_string(),
378        Value::Int(n) => n.to_string(),
379        Value::Float(f) => f.to_string(),
380    }
381}
382
383/// Coerce a numeric-ish [`Value`] to `i64`. Float values are truncated
384/// toward zero (documented intent -- matches C's `(long long)float`
385/// semantics that libmagic's `printf` path relies on). String/Bytes
386/// values have no sensible mapping and return `None`.
387#[allow(
388    clippy::cast_possible_truncation,
389    clippy::cast_sign_loss,
390    clippy::cast_possible_wrap
391)]
392fn coerce_to_i64(value: &Value) -> Option<i64> {
393    match value {
394        Value::Int(n) => Some(*n),
395        // u64 -> i64 bit-pattern reinterpret: matches C's implicit
396        // cast in `printf("%lld", (unsigned long long)...)`.
397        Value::Uint(n) => Some(*n as i64),
398        // f64 -> i64 truncation toward zero, matching C behavior for
399        // `printf("%d", (double)...)`.
400        Value::Float(f) => Some(*f as i64),
401        Value::String(_) | Value::Bytes(_) => None,
402    }
403}
404
405/// Coerce a numeric-ish [`Value`] to `u64`. Mirrors [`coerce_to_i64`]
406/// but preserves the unsigned bit pattern when the source is signed.
407#[allow(
408    clippy::cast_possible_truncation,
409    clippy::cast_sign_loss,
410    clippy::cast_precision_loss
411)]
412fn coerce_to_u64(value: &Value) -> Option<u64> {
413    match value {
414        Value::Uint(n) => Some(*n),
415        // i64 -> u64 bit-pattern reinterpret for rendering; parallels
416        // the `coerce_to_i64` case.
417        Value::Int(n) => Some(*n as u64),
418        Value::Float(f) => Some(*f as u64),
419        Value::String(_) | Value::Bytes(_) => None,
420    }
421}
422
423/// Coerce a numeric-ish [`Value`] to `u64`, masked to the natural bit
424/// width of `type_kind`. Used by hex/octal specifiers to avoid
425/// surprising sign-extended renderings like `byte = -1` rendering as
426/// `ffffffffffffffff` when the user expected `ff`.
427fn coerce_to_u64_masked(value: &Value, type_kind: &TypeKind) -> Option<u64> {
428    let raw = coerce_to_u64(value)?;
429    let mask = match type_kind.bit_width() {
430        Some(8) => 0xff_u64,
431        Some(16) => 0xffff_u64,
432        Some(32) => 0xffff_ffff_u64,
433        // 64-bit, unknown width, or any other case: no mask needed.
434        _ => return Some(raw),
435    };
436    Some(raw & mask)
437}
438
439/// Render a numeric body with an alt-form prefix (`0x` / `0o` / empty),
440/// applying width and padding correctly.
441///
442/// For zero-padded widths (`%#0Nx`), C printf inserts zeros *between*
443/// the prefix and the digits: `%#06x` + `0xab` -> `0x00ab`, not
444/// `  0xab`. For space-padded widths (`%#Nx`), the spaces go *before*
445/// the prefix: `%#6x` + `0xab` -> `  0xab`. For left-aligned widths
446/// (`%-#6x`), trailing spaces follow the digits: `0xab  `.
447fn render_prefixed_int(digits: &str, prefix: &str, spec: &Spec) -> String {
448    // The effective body length for width comparison is prefix + digits.
449    let body_len = prefix.len() + digits.len();
450    if body_len >= spec.width {
451        return format!("{prefix}{digits}");
452    }
453    let pad = spec.width - body_len;
454    if spec.zero_pad && !spec.left_align {
455        // Zeros insert between the prefix and the digits.
456        let zeros: String = std::iter::repeat_n('0', pad).collect();
457        format!("{prefix}{zeros}{digits}")
458    } else if spec.left_align {
459        let spaces: String = std::iter::repeat_n(' ', pad).collect();
460        format!("{prefix}{digits}{spaces}")
461    } else {
462        let spaces: String = std::iter::repeat_n(' ', pad).collect();
463        format!("{spaces}{prefix}{digits}")
464    }
465}
466
467/// Apply width and alignment to a non-numeric rendered body using space-only padding.
468///
469/// Used for `%c` (and any other non-numeric conversion where the POSIX `0` flag
470/// must be ignored). Zero-padding is not applied regardless of `spec.zero_pad`.
471fn pad_non_numeric(body: &str, spec: &Spec) -> String {
472    if body.len() >= spec.width {
473        return body.to_string();
474    }
475    let pad = spec.width - body.len();
476    let padding: String = std::iter::repeat_n(' ', pad).collect();
477    if spec.left_align {
478        format!("{body}{padding}")
479    } else {
480        format!("{padding}{body}")
481    }
482}
483
484/// Apply width and padding to an already-rendered numeric body.
485///
486/// For zero-padded right-aligned formatting, a leading `-` sign is kept at
487/// the front while zeros are inserted between the sign and the magnitude
488/// digits -- matching C printf semantics (e.g., `%05d` with `-7` → `-0007`,
489/// not `000-7`).
490fn pad_numeric(body: &str, spec: &Spec) -> String {
491    if body.len() >= spec.width {
492        return body.to_string();
493    }
494    // C printf sign-aware zero-padding: sign goes before the zeros.
495    if spec.zero_pad
496        && !spec.left_align
497        && let Some(digits) = body.strip_prefix('-')
498    {
499        let needed = spec.width.saturating_sub(1 + digits.len());
500        if needed == 0 {
501            return body.to_string();
502        }
503        let zeros: String = std::iter::repeat_n('0', needed).collect();
504        return format!("-{zeros}{digits}");
505    }
506    let pad = spec.width - body.len();
507    let pad_char = if spec.zero_pad && !spec.left_align {
508        '0'
509    } else {
510        ' '
511    };
512    let padding: String = std::iter::repeat_n(pad_char, pad).collect();
513    if spec.left_align {
514        format!("{body}{padding}")
515    } else {
516        format!("{padding}{body}")
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    // Restriction lints without an allow-*-in-tests config option;
523    // non-ASCII test data exercises the UTF-8-safe format scanner.
524    #![allow(clippy::non_ascii_literal)]
525
526    use super::*;
527    use crate::parser::ast::StringFlags;
528
529    fn byte_t() -> TypeKind {
530        TypeKind::Byte { signed: false }
531    }
532
533    fn long_t() -> TypeKind {
534        TypeKind::Long {
535            endian: crate::parser::ast::Endianness::Little,
536            signed: true,
537        }
538    }
539
540    // ---- happy path --------------------------------------------------
541
542    #[test]
543    fn test_signed_decimal_substitution() {
544        // Covers %d, %i, %ld, %lld (length modifiers are accepted and ignored).
545        let cases = [
546            ("v=%d", Value::Int(-7), "v=-7"),
547            ("v=%i", Value::Int(42), "v=42"),
548            ("v=%ld", Value::Int(10), "v=10"),
549            ("at_offset %lld", Value::Uint(11), "at_offset 11"),
550        ];
551        for (tmpl, val, expected) in cases {
552            assert_eq!(
553                format_magic_message(tmpl, &val, &byte_t()),
554                expected,
555                "template {tmpl:?} with value {val:?}",
556            );
557        }
558    }
559
560    #[test]
561    fn test_unsigned_decimal_substitution() {
562        let out = format_magic_message("n=%u", &Value::Uint(200), &byte_t());
563        assert_eq!(out, "n=200");
564
565        // i64::MIN as unsigned should come through as 2^63.
566        let out = format_magic_message("n=%llu", &Value::Int(i64::MIN), &long_t());
567        assert_eq!(out, "n=9223372036854775808");
568    }
569
570    #[test]
571    fn test_hex_substitution_with_byte_width_masking() {
572        // The canonical searchbug.result case: ubyte `%02x`.
573        let out = format_magic_message("0x%02x", &Value::Uint(0x31), &byte_t());
574        assert_eq!(out, "0x31");
575
576        // Byte -1 (sign-extended to u64::MAX in Value::Int) must render as "ff",
577        // not "ffffffffffffffff", when the underlying type is a byte.
578        let out = format_magic_message("0x%02x", &Value::Int(-1), &byte_t());
579        assert_eq!(out, "0xff");
580
581        // %X is uppercase.
582        let out = format_magic_message("%X", &Value::Uint(0xdead_beef), &long_t());
583        assert_eq!(out, "DEADBEEF");
584
585        // %#x emits the "0x" prefix via alt form.
586        let out = format_magic_message("%#x", &Value::Uint(0xab), &byte_t());
587        assert_eq!(out, "0xab");
588
589        // %#06x: zero-pad inserts between prefix and digits (C printf semantics),
590        // not before the prefix. Regression guard for correctness review COR-002.
591        let out = format_magic_message("%#06x", &Value::Uint(0xab), &byte_t());
592        assert_eq!(out, "0x00ab");
593
594        // Space-padded width with alt-form prefix: spaces go before prefix.
595        let out = format_magic_message("%#6x", &Value::Uint(0xab), &byte_t());
596        assert_eq!(out, "  0xab");
597
598        // Left-aligned with alt-form prefix: spaces trail the digits.
599        let out = format_magic_message("%-#6x|", &Value::Uint(0xab), &byte_t());
600        assert_eq!(out, "0xab  |");
601
602        // %#08o: zero-pad inserts between C-style "0" prefix and digits.
603        // C printf uses a single "0" prefix for %#o (not Rust's "0o").
604        let out = format_magic_message("%#08o", &Value::Uint(8), &byte_t());
605        assert_eq!(out, "00000010");
606
607        // %#X: uppercase alt-form uses "0X" prefix to match the specifier case.
608        let out = format_magic_message("%#X", &Value::Uint(0xab), &byte_t());
609        assert_eq!(out, "0XAB");
610    }
611
612    #[test]
613    fn test_string_substitution() {
614        let out = format_magic_message(
615            "hello %s",
616            &Value::String("world".to_string()),
617            &TypeKind::String {
618                max_length: None,
619                flags: StringFlags::default(),
620            },
621        );
622        assert_eq!(out, "hello world");
623
624        // Bytes go through lossy UTF-8.
625        let out = format_magic_message(
626            "data=%s",
627            &Value::Bytes(b"abc".to_vec()),
628            &TypeKind::String {
629                max_length: None,
630                flags: StringFlags::default(),
631            },
632        );
633        assert_eq!(out, "data=abc");
634    }
635
636    #[test]
637    fn test_string_precision_truncation() {
638        // `%.Ns` truncates the rendered string to at most N characters.
639        // sgml's `>15 string/t >\0 %.3s document text` is the motivating
640        // rule: the full XML-version field is `1.0" encoding=...` but `%.3s`
641        // must render only `1.0` so the description reads `XML 1.0 ...`.
642        let str_t = TypeKind::String {
643            max_length: None,
644            flags: StringFlags::default(),
645        };
646        // (template, value, expected)
647        let cases: &[(&str, &str, &str)] = &[
648            // The XML case: full field truncated to 3 chars.
649            (
650                "%.3s document text",
651                "1.0\" encoding=\"UTF-8\"?>",
652                "1.0 document text",
653            ),
654            // Precision shorter than the string truncates.
655            ("%.1s", "1.0", "1"),
656            // Precision >= length is a no-op (no padding without width).
657            ("%.10s", "abc", "abc"),
658            ("%.3s", "abc", "abc"),
659            // Precision 0 renders the empty string.
660            ("%.0s", "abc", ""),
661            // Left-align precision (`-` is a no-op here since no width).
662            ("%-.4s", "versionX", "vers"),
663            // Width padding is applied AFTER truncation: `%4.4s` on "ab"
664            // truncates to "ab" (no-op) then right-aligns to width 4 (pads on
665            // the LEFT).
666            ("[%4.4s]", "ab", "[  ab]"),
667            // `%-4.2s`: truncate "hello" to "he", then `-` left-aligns to
668            // width 4 (pads on the RIGHT).
669            ("[%-4.2s]", "hello", "[he  ]"),
670        ];
671        for (template, value, expected) in cases {
672            let out = format_magic_message(template, &Value::String((*value).to_string()), &str_t);
673            assert_eq!(
674                out, *expected,
675                "template {template:?} on value {value:?} should render {expected:?}",
676            );
677        }
678    }
679
680    #[test]
681    fn test_alt_form_prefix_suppressed_on_zero_value() {
682        // C printf special-cases `%#o`, `%#x`, `%#X` with value 0: the
683        // alt-form prefix is suppressed because the rendered digit
684        // already begins with `0`. Regression guard after pr-review
685        // caught that our implementation emitted `"00"` / `"0x0"` /
686        // `"0X0"` for zero values.
687        let out = format_magic_message("%#o", &Value::Uint(0), &byte_t());
688        assert_eq!(out, "0", "%#o with 0 must emit single '0', not '00'");
689
690        let out = format_magic_message("%#x", &Value::Uint(0), &byte_t());
691        assert_eq!(out, "0", "%#x with 0 must emit single '0', not '0x0'");
692
693        let out = format_magic_message("%#X", &Value::Uint(0), &byte_t());
694        assert_eq!(out, "0", "%#X with 0 must emit single '0', not '0X0'");
695
696        // Non-zero values still get the prefix.
697        let out = format_magic_message("%#x", &Value::Uint(1), &byte_t());
698        assert_eq!(out, "0x1");
699    }
700
701    #[test]
702    fn test_octal_substitution() {
703        let out = format_magic_message("%o", &Value::Uint(8), &byte_t());
704        assert_eq!(out, "10");
705        // C printf %#o uses a single "0" prefix, not Rust's "0o".
706        let out = format_magic_message("%#o", &Value::Uint(8), &byte_t());
707        assert_eq!(out, "010");
708    }
709
710    #[test]
711    fn test_char_substitution() {
712        let out = format_magic_message("[%c]", &Value::Uint(u64::from(b'A')), &byte_t());
713        assert_eq!(out, "[A]");
714
715        // Full 0x00-0xff range: bytes >= 0x80 are embedded as Latin-1 code points.
716        let out = format_magic_message("%c", &Value::Uint(0xa9), &byte_t());
717        assert_eq!(out, "\u{00a9}"); // U+00A9 COPYRIGHT SIGN
718
719        // Width with space-padding (right-aligned).
720        let out = format_magic_message("%3c", &Value::Uint(u64::from(b'A')), &byte_t());
721        assert_eq!(out, "  A");
722
723        // Left-aligned width.
724        let out = format_magic_message("%-3c|", &Value::Uint(u64::from(b'A')), &byte_t());
725        assert_eq!(out, "A  |");
726    }
727
728    #[test]
729    fn test_char_zero_flag_ignored() {
730        // POSIX: the `0` flag is ignored for `%c` -- zero-padding applies only to
731        // numeric conversions. `%03c` must produce space-padded "  A", not "00A".
732        // Regression guard: an earlier revision called `pad_numeric` for `Conv::Char`,
733        // which applied zero-padding and diverged from C printf semantics.
734        let out = format_magic_message("%03c", &Value::Uint(u64::from(b'A')), &byte_t());
735        assert_eq!(out, "  A", "%03c must use space-padding, not zero-padding");
736
737        // Combined zero and left-align: `-` overrides `0` for numerics; for %c
738        // `0` was never active, but `-` still triggers left-alignment.
739        let out = format_magic_message("%-03c|", &Value::Uint(u64::from(b'A')), &byte_t());
740        assert_eq!(out, "A  |", "%-03c must left-align with spaces");
741    }
742
743    #[test]
744    fn test_percent_escape() {
745        let out = format_magic_message("100%% sure", &Value::Uint(0), &byte_t());
746        assert_eq!(out, "100% sure");
747    }
748
749    #[test]
750    fn test_non_ascii_template_preserved() {
751        // Regression guard: earlier revisions iterated by byte and
752        // pushed each `b as char`, which re-encoded non-ASCII UTF-8
753        // continuation bytes as Latin-1 code points and mangled the
754        // output (e.g., "café" -> "café"). The plain-run flush path
755        // must copy slices of the original template to preserve the
756        // original UTF-8 byte sequences.
757        let out = format_magic_message("café %d", &Value::Int(42), &long_t());
758        assert_eq!(out, "café 42");
759
760        // Non-ASCII around a specifier on both sides.
761        let out = format_magic_message("→ %s ←", &Value::String("ok".into()), &byte_t());
762        assert_eq!(out, "→ ok ←");
763
764        // Non-ASCII only, no specifiers.
765        let out = format_magic_message("über", &Value::Uint(0), &byte_t());
766        assert_eq!(out, "über");
767    }
768
769    #[test]
770    fn test_multiple_specifiers_in_one_template() {
771        // Note: current implementation binds every specifier to the single
772        // `value`; multiple specifiers are rendered against the same value.
773        // This matches libmagic's single-argument model -- magic rules only
774        // expose one read value per rule.
775        let out = format_magic_message("a=%d b=%d", &Value::Int(5), &long_t());
776        assert_eq!(out, "a=5 b=5");
777    }
778
779    #[test]
780    fn test_width_padding() {
781        // Zero-padded width with negative value: sign must precede zeros.
782        // Regression guard for sign-aware zero-padding (C printf semantics).
783        let out = format_magic_message("%05d", &Value::Int(-7), &long_t());
784        assert_eq!(out, "-0007");
785        let out = format_magic_message("%06d", &Value::Int(-42), &long_t());
786        assert_eq!(out, "-00042");
787        // Zero-padded width.
788        let out = format_magic_message("%05d", &Value::Int(42), &long_t());
789        assert_eq!(out, "00042");
790        // Space-padded width.
791        let out = format_magic_message("%5d", &Value::Int(42), &long_t());
792        assert_eq!(out, "   42");
793        // Negative with space-padding: sign stays in the body, spaces lead.
794        let out = format_magic_message("%5d", &Value::Int(-7), &long_t());
795        assert_eq!(out, "   -7");
796        // Left-aligned (zero flag ignored when `-` is set).
797        let out = format_magic_message("%-5d|", &Value::Int(42), &long_t());
798        assert_eq!(out, "42   |");
799        // Left-aligned negative: body left-aligned, spaces trail.
800        let out = format_magic_message("%-6d|", &Value::Int(-7), &long_t());
801        assert_eq!(out, "-7    |");
802    }
803
804    #[test]
805    fn test_width_cap_prevents_large_allocation() {
806        // A width larger than MAX_FORMAT_WIDTH must be silently clamped.
807        // The output should be valid (the value rendered, possibly padded)
808        // rather than triggering a huge allocation.
809        let huge_width = format!("%{}d", usize::MAX);
810        let out = format_magic_message(&huge_width, &Value::Int(1), &long_t());
811        // After clamping, the output is at most MAX_FORMAT_WIDTH+1 chars.
812        assert!(
813            out.len() <= MAX_FORMAT_WIDTH + 1,
814            "output too long: {}",
815            out.len()
816        );
817        assert!(out.ends_with('1'), "rendered value must appear: {out:?}");
818    }
819
820    // ---- edge cases --------------------------------------------------
821
822    #[test]
823    fn test_empty_template() {
824        assert_eq!(
825            format_magic_message("", &Value::Uint(0), &byte_t()),
826            String::new()
827        );
828    }
829
830    #[test]
831    fn test_literal_with_no_specifiers() {
832        assert_eq!(
833            format_magic_message("hello world", &Value::Uint(0), &byte_t()),
834            "hello world"
835        );
836    }
837
838    #[test]
839    fn test_trailing_percent_with_no_spec() {
840        // A stray `%` at end-of-string: pass through literally.
841        let out = format_magic_message("done %", &Value::Uint(0), &byte_t());
842        assert_eq!(out, "done %");
843    }
844
845    #[test]
846    fn test_unknown_specifier_pass_through() {
847        // `%q` is not in our subset.
848        let out = format_magic_message("bad %q end", &Value::Uint(0), &byte_t());
849        assert_eq!(out, "bad %q end");
850    }
851
852    #[test]
853    fn test_type_mismatch_string_conv_on_uint_still_renders() {
854        // `%s` against an integer value -- GNU `file` renders the number
855        // as decimal; libmagic-rs matches that behavior via `render_string`.
856        let out = format_magic_message("v=%s", &Value::Uint(42), &byte_t());
857        assert_eq!(out, "v=42");
858    }
859
860    #[test]
861    fn test_type_mismatch_numeric_conv_on_string_passes_through() {
862        // `%d` against a string has no sensible coercion -> literal.
863        let out = format_magic_message(
864            "v=%d",
865            &Value::String("hi".to_string()),
866            &TypeKind::String {
867                max_length: None,
868                flags: StringFlags::default(),
869            },
870        );
871        assert_eq!(out, "v=%d");
872    }
873
874    #[test]
875    fn test_char_specifier_accepts_full_byte_range() {
876        // `%c` emits every byte value 0x00..=0xff directly, matching
877        // GNU `file` / C printf semantics. Bytes 0x80-0xff are embedded
878        // as their Latin-1 code points via `char::from(u8)`.
879        // 0xff maps to U+00FF ('ÿ'); UTF-8 encoding is 0xc3 0xbf.
880        let out = format_magic_message("[%c]", &Value::Uint(0xff), &byte_t());
881        assert_eq!(out, "[\u{00ff}]");
882
883        // ASCII boundary stays unchanged.
884        let out = format_magic_message("[%c]", &Value::Uint(u64::from(b'A')), &byte_t());
885        assert_eq!(out, "[A]");
886
887        // Out-of-range (doesn't fit u8) passes through literally.
888        let out = format_magic_message("[%c]", &Value::Uint(0x1_0000), &byte_t());
889        assert_eq!(out, "[%c]");
890    }
891
892    #[test]
893    fn test_byte_width_masking_on_negative_signed_byte() {
894        // Regression guard: a signed byte carrying -1 (the representation
895        // on the Value side is Int(-1)) must NOT render as a 64-bit mask.
896        let out = format_magic_message("%x", &Value::Int(-1), &byte_t());
897        assert_eq!(out, "ff");
898    }
899
900    #[test]
901    fn test_hex_width_masking_respects_16bit() {
902        let short_t = TypeKind::Short {
903            endian: crate::parser::ast::Endianness::Little,
904            signed: true,
905        };
906        let out = format_magic_message("%x", &Value::Int(-1), &short_t);
907        assert_eq!(out, "ffff");
908    }
909}