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 conv: Conv,
166 /// Byte index of the character *after* this specifier in the template.
167 end: usize,
168}
169
170/// Maximum width value accepted from a format specifier.
171///
172/// Caps the field width to prevent crafted magic rules with enormous widths
173/// (e.g., `%999999999d`) from driving unbounded `repeat_n` allocations in the
174/// padding helpers. 4096 is generous for any real magic-corpus usage.
175const MAX_FORMAT_WIDTH: usize = 4096;
176
177/// Parse a format specifier starting at `start` (the first byte after the
178/// leading `%`). Returns `None` if the sequence does not end in a
179/// recognized conversion character.
180// Indexing is invariant-safe: every `bytes[i]` is guarded by an
181// `i < bytes.len()` loop or branch condition.
182#[allow(clippy::indexing_slicing)]
183fn parse_spec(bytes: &[u8], start: usize) -> Option<Spec> {
184 let mut i = start;
185 let mut zero_pad = false;
186 let mut left_align = false;
187 let mut alt_form = false;
188
189 // Flags (subset: 0, -, #). Other flags (+, space) are parsed but ignored.
190 while i < bytes.len() {
191 match bytes[i] {
192 b'0' => {
193 zero_pad = true;
194 i += 1;
195 }
196 b'-' => {
197 left_align = true;
198 i += 1;
199 }
200 b'#' => {
201 alt_form = true;
202 i += 1;
203 }
204 b'+' | b' ' => {
205 // Accepted for syntactic completeness, no rendering effect
206 // in the current subset.
207 i += 1;
208 }
209 _ => break,
210 }
211 }
212
213 // Width (decimal digits). Capped at MAX_FORMAT_WIDTH to prevent
214 // unbounded allocations from crafted format strings.
215 let mut width: usize = 0;
216 while i < bytes.len() && bytes[i].is_ascii_digit() {
217 let digit = (bytes[i] - b'0') as usize;
218 width = width
219 .saturating_mul(10)
220 .saturating_add(digit)
221 .min(MAX_FORMAT_WIDTH);
222 i += 1;
223 }
224
225 // Precision (`.<digits>`): parsed and skipped -- no current consumer
226 // requires precision handling, and numeric rendering is whole-value.
227 if i < bytes.len() && bytes[i] == b'.' {
228 i += 1;
229 while i < bytes.len() && bytes[i].is_ascii_digit() {
230 i += 1;
231 }
232 }
233
234 // Length modifier (`h`, `hh`, `l`, `ll`, `j`, `z`, `t`). We consume
235 // these for syntactic completeness but never rely on them -- all
236 // numeric rendering uses full u64/i64 width.
237 while i < bytes.len() {
238 match bytes[i] {
239 b'l' | b'h' | b'j' | b'z' | b't' => i += 1,
240 _ => break,
241 }
242 }
243
244 if i >= bytes.len() {
245 return None;
246 }
247
248 let conv = match bytes[i] {
249 b'd' | b'i' => Conv::Signed,
250 b'u' => Conv::Unsigned,
251 b'x' => Conv::HexLower,
252 b'X' => Conv::HexUpper,
253 b'o' => Conv::Octal,
254 b's' => Conv::Str,
255 b'c' => Conv::Char,
256 b'%' => Conv::Percent,
257 _ => return None,
258 };
259 i += 1;
260
261 Some(Spec {
262 zero_pad,
263 left_align,
264 alt_form,
265 width,
266 conv,
267 end: i,
268 })
269}
270
271/// Render the specifier against `value`, or return `None` if the value
272/// is type-incompatible with the conversion.
273fn render(spec: &Spec, value: &Value, type_kind: &TypeKind) -> Option<String> {
274 match spec.conv {
275 Conv::Percent => Some("%".to_string()),
276 Conv::Str => Some(render_string(value)),
277 Conv::Signed => {
278 let n = coerce_to_i64(value)?;
279 Some(pad_numeric(&n.to_string(), spec))
280 }
281 Conv::Unsigned => {
282 let n = coerce_to_u64(value)?;
283 Some(pad_numeric(&n.to_string(), spec))
284 }
285 Conv::HexLower => {
286 let n = coerce_to_u64_masked(value, type_kind)?;
287 // C printf suppresses the `0x`/`0X` alt-form prefix when the
288 // value is zero: `printf("%#x", 0)` emits `"0"`, not `"0x0"`.
289 let prefix = if spec.alt_form && n != 0 { "0x" } else { "" };
290 Some(render_prefixed_int(&format!("{n:x}"), prefix, spec))
291 }
292 Conv::HexUpper => {
293 let n = coerce_to_u64_masked(value, type_kind)?;
294 let prefix = if spec.alt_form && n != 0 { "0X" } else { "" };
295 Some(render_prefixed_int(&format!("{n:X}"), prefix, spec))
296 }
297 Conv::Octal => {
298 let n = coerce_to_u64_masked(value, type_kind)?;
299 // C printf uses a single "0" prefix for %#o (not Rust's "0o"),
300 // and suppresses the prefix when the value itself is zero --
301 // the resulting digit `0` already satisfies the "starts with
302 // 0" invariant that the alt-form is meant to guarantee.
303 let prefix = if spec.alt_form && n != 0 { "0" } else { "" };
304 Some(render_prefixed_int(&format!("{n:o}"), prefix, spec))
305 }
306 Conv::Char => {
307 let n = coerce_to_u64(value)?;
308 let byte = u8::try_from(n).ok()?;
309 // GNU `file` / C printf `%c` converts the int argument to
310 // unsigned char and emits it directly for all byte values
311 // 0x00-0xff. Rust's `String` must be valid UTF-8, so we
312 // embed bytes >= 0x80 as their Latin-1 code points (U+0080
313 // through U+00FF) via `char::from(u8)` which is infallible
314 // and lossless. Consumers with UTF-8 terminals see the
315 // 2-byte UTF-8 encoding of that code point; consumers
316 // iterating the returned bytes directly can recover the
317 // original byte by re-encoding the code point as Latin-1.
318 //
319 // POSIX: the `0` flag is ignored for `%c` -- zero-padding only
320 // applies to numeric/float conversions. Always use space-padding
321 // for `%c`, matching C printf behavior.
322 Some(pad_non_numeric(&char::from(byte).to_string(), spec))
323 }
324 }
325}
326
327/// Render a [`Value`] for `%s`. Strings pass through; byte sequences are
328/// converted via lossy UTF-8; numbers render as decimal (GNU `file` does
329/// the same for mixed-type `%s` substitutions).
330fn render_string(value: &Value) -> String {
331 match value {
332 Value::String(s) => s.clone(),
333 Value::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
334 Value::Uint(n) => n.to_string(),
335 Value::Int(n) => n.to_string(),
336 Value::Float(f) => f.to_string(),
337 }
338}
339
340/// Coerce a numeric-ish [`Value`] to `i64`. Float values are truncated
341/// toward zero (documented intent -- matches C's `(long long)float`
342/// semantics that libmagic's `printf` path relies on). String/Bytes
343/// values have no sensible mapping and return `None`.
344#[allow(
345 clippy::cast_possible_truncation,
346 clippy::cast_sign_loss,
347 clippy::cast_possible_wrap
348)]
349fn coerce_to_i64(value: &Value) -> Option<i64> {
350 match value {
351 Value::Int(n) => Some(*n),
352 // u64 -> i64 bit-pattern reinterpret: matches C's implicit
353 // cast in `printf("%lld", (unsigned long long)...)`.
354 Value::Uint(n) => Some(*n as i64),
355 // f64 -> i64 truncation toward zero, matching C behavior for
356 // `printf("%d", (double)...)`.
357 Value::Float(f) => Some(*f as i64),
358 Value::String(_) | Value::Bytes(_) => None,
359 }
360}
361
362/// Coerce a numeric-ish [`Value`] to `u64`. Mirrors [`coerce_to_i64`]
363/// but preserves the unsigned bit pattern when the source is signed.
364#[allow(
365 clippy::cast_possible_truncation,
366 clippy::cast_sign_loss,
367 clippy::cast_precision_loss
368)]
369fn coerce_to_u64(value: &Value) -> Option<u64> {
370 match value {
371 Value::Uint(n) => Some(*n),
372 // i64 -> u64 bit-pattern reinterpret for rendering; parallels
373 // the `coerce_to_i64` case.
374 Value::Int(n) => Some(*n as u64),
375 Value::Float(f) => Some(*f as u64),
376 Value::String(_) | Value::Bytes(_) => None,
377 }
378}
379
380/// Coerce a numeric-ish [`Value`] to `u64`, masked to the natural bit
381/// width of `type_kind`. Used by hex/octal specifiers to avoid
382/// surprising sign-extended renderings like `byte = -1` rendering as
383/// `ffffffffffffffff` when the user expected `ff`.
384fn coerce_to_u64_masked(value: &Value, type_kind: &TypeKind) -> Option<u64> {
385 let raw = coerce_to_u64(value)?;
386 let mask = match type_kind.bit_width() {
387 Some(8) => 0xff_u64,
388 Some(16) => 0xffff_u64,
389 Some(32) => 0xffff_ffff_u64,
390 // 64-bit, unknown width, or any other case: no mask needed.
391 _ => return Some(raw),
392 };
393 Some(raw & mask)
394}
395
396/// Render a numeric body with an alt-form prefix (`0x` / `0o` / empty),
397/// applying width and padding correctly.
398///
399/// For zero-padded widths (`%#0Nx`), C printf inserts zeros *between*
400/// the prefix and the digits: `%#06x` + `0xab` -> `0x00ab`, not
401/// ` 0xab`. For space-padded widths (`%#Nx`), the spaces go *before*
402/// the prefix: `%#6x` + `0xab` -> ` 0xab`. For left-aligned widths
403/// (`%-#6x`), trailing spaces follow the digits: `0xab `.
404fn render_prefixed_int(digits: &str, prefix: &str, spec: &Spec) -> String {
405 // The effective body length for width comparison is prefix + digits.
406 let body_len = prefix.len() + digits.len();
407 if body_len >= spec.width {
408 return format!("{prefix}{digits}");
409 }
410 let pad = spec.width - body_len;
411 if spec.zero_pad && !spec.left_align {
412 // Zeros insert between the prefix and the digits.
413 let zeros: String = std::iter::repeat_n('0', pad).collect();
414 format!("{prefix}{zeros}{digits}")
415 } else if spec.left_align {
416 let spaces: String = std::iter::repeat_n(' ', pad).collect();
417 format!("{prefix}{digits}{spaces}")
418 } else {
419 let spaces: String = std::iter::repeat_n(' ', pad).collect();
420 format!("{spaces}{prefix}{digits}")
421 }
422}
423
424/// Apply width and alignment to a non-numeric rendered body using space-only padding.
425///
426/// Used for `%c` (and any other non-numeric conversion where the POSIX `0` flag
427/// must be ignored). Zero-padding is not applied regardless of `spec.zero_pad`.
428fn pad_non_numeric(body: &str, spec: &Spec) -> String {
429 if body.len() >= spec.width {
430 return body.to_string();
431 }
432 let pad = spec.width - body.len();
433 let padding: String = std::iter::repeat_n(' ', pad).collect();
434 if spec.left_align {
435 format!("{body}{padding}")
436 } else {
437 format!("{padding}{body}")
438 }
439}
440
441/// Apply width and padding to an already-rendered numeric body.
442///
443/// For zero-padded right-aligned formatting, a leading `-` sign is kept at
444/// the front while zeros are inserted between the sign and the magnitude
445/// digits -- matching C printf semantics (e.g., `%05d` with `-7` → `-0007`,
446/// not `000-7`).
447fn pad_numeric(body: &str, spec: &Spec) -> String {
448 if body.len() >= spec.width {
449 return body.to_string();
450 }
451 // C printf sign-aware zero-padding: sign goes before the zeros.
452 if spec.zero_pad
453 && !spec.left_align
454 && let Some(digits) = body.strip_prefix('-')
455 {
456 let needed = spec.width.saturating_sub(1 + digits.len());
457 if needed == 0 {
458 return body.to_string();
459 }
460 let zeros: String = std::iter::repeat_n('0', needed).collect();
461 return format!("-{zeros}{digits}");
462 }
463 let pad = spec.width - body.len();
464 let pad_char = if spec.zero_pad && !spec.left_align {
465 '0'
466 } else {
467 ' '
468 };
469 let padding: String = std::iter::repeat_n(pad_char, pad).collect();
470 if spec.left_align {
471 format!("{body}{padding}")
472 } else {
473 format!("{padding}{body}")
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 // Restriction lints without an allow-*-in-tests config option;
480 // non-ASCII test data exercises the UTF-8-safe format scanner.
481 #![allow(clippy::non_ascii_literal)]
482
483 use super::*;
484 use crate::parser::ast::StringFlags;
485
486 fn byte_t() -> TypeKind {
487 TypeKind::Byte { signed: false }
488 }
489
490 fn long_t() -> TypeKind {
491 TypeKind::Long {
492 endian: crate::parser::ast::Endianness::Little,
493 signed: true,
494 }
495 }
496
497 // ---- happy path --------------------------------------------------
498
499 #[test]
500 fn test_signed_decimal_substitution() {
501 // Covers %d, %i, %ld, %lld (length modifiers are accepted and ignored).
502 let cases = [
503 ("v=%d", Value::Int(-7), "v=-7"),
504 ("v=%i", Value::Int(42), "v=42"),
505 ("v=%ld", Value::Int(10), "v=10"),
506 ("at_offset %lld", Value::Uint(11), "at_offset 11"),
507 ];
508 for (tmpl, val, expected) in cases {
509 assert_eq!(
510 format_magic_message(tmpl, &val, &byte_t()),
511 expected,
512 "template {tmpl:?} with value {val:?}",
513 );
514 }
515 }
516
517 #[test]
518 fn test_unsigned_decimal_substitution() {
519 let out = format_magic_message("n=%u", &Value::Uint(200), &byte_t());
520 assert_eq!(out, "n=200");
521
522 // i64::MIN as unsigned should come through as 2^63.
523 let out = format_magic_message("n=%llu", &Value::Int(i64::MIN), &long_t());
524 assert_eq!(out, "n=9223372036854775808");
525 }
526
527 #[test]
528 fn test_hex_substitution_with_byte_width_masking() {
529 // The canonical searchbug.result case: ubyte `%02x`.
530 let out = format_magic_message("0x%02x", &Value::Uint(0x31), &byte_t());
531 assert_eq!(out, "0x31");
532
533 // Byte -1 (sign-extended to u64::MAX in Value::Int) must render as "ff",
534 // not "ffffffffffffffff", when the underlying type is a byte.
535 let out = format_magic_message("0x%02x", &Value::Int(-1), &byte_t());
536 assert_eq!(out, "0xff");
537
538 // %X is uppercase.
539 let out = format_magic_message("%X", &Value::Uint(0xdead_beef), &long_t());
540 assert_eq!(out, "DEADBEEF");
541
542 // %#x emits the "0x" prefix via alt form.
543 let out = format_magic_message("%#x", &Value::Uint(0xab), &byte_t());
544 assert_eq!(out, "0xab");
545
546 // %#06x: zero-pad inserts between prefix and digits (C printf semantics),
547 // not before the prefix. Regression guard for correctness review COR-002.
548 let out = format_magic_message("%#06x", &Value::Uint(0xab), &byte_t());
549 assert_eq!(out, "0x00ab");
550
551 // Space-padded width with alt-form prefix: spaces go before prefix.
552 let out = format_magic_message("%#6x", &Value::Uint(0xab), &byte_t());
553 assert_eq!(out, " 0xab");
554
555 // Left-aligned with alt-form prefix: spaces trail the digits.
556 let out = format_magic_message("%-#6x|", &Value::Uint(0xab), &byte_t());
557 assert_eq!(out, "0xab |");
558
559 // %#08o: zero-pad inserts between C-style "0" prefix and digits.
560 // C printf uses a single "0" prefix for %#o (not Rust's "0o").
561 let out = format_magic_message("%#08o", &Value::Uint(8), &byte_t());
562 assert_eq!(out, "00000010");
563
564 // %#X: uppercase alt-form uses "0X" prefix to match the specifier case.
565 let out = format_magic_message("%#X", &Value::Uint(0xab), &byte_t());
566 assert_eq!(out, "0XAB");
567 }
568
569 #[test]
570 fn test_string_substitution() {
571 let out = format_magic_message(
572 "hello %s",
573 &Value::String("world".to_string()),
574 &TypeKind::String {
575 max_length: None,
576 flags: StringFlags::default(),
577 },
578 );
579 assert_eq!(out, "hello world");
580
581 // Bytes go through lossy UTF-8.
582 let out = format_magic_message(
583 "data=%s",
584 &Value::Bytes(b"abc".to_vec()),
585 &TypeKind::String {
586 max_length: None,
587 flags: StringFlags::default(),
588 },
589 );
590 assert_eq!(out, "data=abc");
591 }
592
593 #[test]
594 fn test_alt_form_prefix_suppressed_on_zero_value() {
595 // C printf special-cases `%#o`, `%#x`, `%#X` with value 0: the
596 // alt-form prefix is suppressed because the rendered digit
597 // already begins with `0`. Regression guard after pr-review
598 // caught that our implementation emitted `"00"` / `"0x0"` /
599 // `"0X0"` for zero values.
600 let out = format_magic_message("%#o", &Value::Uint(0), &byte_t());
601 assert_eq!(out, "0", "%#o with 0 must emit single '0', not '00'");
602
603 let out = format_magic_message("%#x", &Value::Uint(0), &byte_t());
604 assert_eq!(out, "0", "%#x with 0 must emit single '0', not '0x0'");
605
606 let out = format_magic_message("%#X", &Value::Uint(0), &byte_t());
607 assert_eq!(out, "0", "%#X with 0 must emit single '0', not '0X0'");
608
609 // Non-zero values still get the prefix.
610 let out = format_magic_message("%#x", &Value::Uint(1), &byte_t());
611 assert_eq!(out, "0x1");
612 }
613
614 #[test]
615 fn test_octal_substitution() {
616 let out = format_magic_message("%o", &Value::Uint(8), &byte_t());
617 assert_eq!(out, "10");
618 // C printf %#o uses a single "0" prefix, not Rust's "0o".
619 let out = format_magic_message("%#o", &Value::Uint(8), &byte_t());
620 assert_eq!(out, "010");
621 }
622
623 #[test]
624 fn test_char_substitution() {
625 let out = format_magic_message("[%c]", &Value::Uint(u64::from(b'A')), &byte_t());
626 assert_eq!(out, "[A]");
627
628 // Full 0x00-0xff range: bytes >= 0x80 are embedded as Latin-1 code points.
629 let out = format_magic_message("%c", &Value::Uint(0xa9), &byte_t());
630 assert_eq!(out, "\u{00a9}"); // U+00A9 COPYRIGHT SIGN
631
632 // Width with space-padding (right-aligned).
633 let out = format_magic_message("%3c", &Value::Uint(u64::from(b'A')), &byte_t());
634 assert_eq!(out, " A");
635
636 // Left-aligned width.
637 let out = format_magic_message("%-3c|", &Value::Uint(u64::from(b'A')), &byte_t());
638 assert_eq!(out, "A |");
639 }
640
641 #[test]
642 fn test_char_zero_flag_ignored() {
643 // POSIX: the `0` flag is ignored for `%c` -- zero-padding applies only to
644 // numeric conversions. `%03c` must produce space-padded " A", not "00A".
645 // Regression guard: an earlier revision called `pad_numeric` for `Conv::Char`,
646 // which applied zero-padding and diverged from C printf semantics.
647 let out = format_magic_message("%03c", &Value::Uint(u64::from(b'A')), &byte_t());
648 assert_eq!(out, " A", "%03c must use space-padding, not zero-padding");
649
650 // Combined zero and left-align: `-` overrides `0` for numerics; for %c
651 // `0` was never active, but `-` still triggers left-alignment.
652 let out = format_magic_message("%-03c|", &Value::Uint(u64::from(b'A')), &byte_t());
653 assert_eq!(out, "A |", "%-03c must left-align with spaces");
654 }
655
656 #[test]
657 fn test_percent_escape() {
658 let out = format_magic_message("100%% sure", &Value::Uint(0), &byte_t());
659 assert_eq!(out, "100% sure");
660 }
661
662 #[test]
663 fn test_non_ascii_template_preserved() {
664 // Regression guard: earlier revisions iterated by byte and
665 // pushed each `b as char`, which re-encoded non-ASCII UTF-8
666 // continuation bytes as Latin-1 code points and mangled the
667 // output (e.g., "café" -> "café"). The plain-run flush path
668 // must copy slices of the original template to preserve the
669 // original UTF-8 byte sequences.
670 let out = format_magic_message("café %d", &Value::Int(42), &long_t());
671 assert_eq!(out, "café 42");
672
673 // Non-ASCII around a specifier on both sides.
674 let out = format_magic_message("→ %s ←", &Value::String("ok".into()), &byte_t());
675 assert_eq!(out, "→ ok ←");
676
677 // Non-ASCII only, no specifiers.
678 let out = format_magic_message("über", &Value::Uint(0), &byte_t());
679 assert_eq!(out, "über");
680 }
681
682 #[test]
683 fn test_multiple_specifiers_in_one_template() {
684 // Note: current implementation binds every specifier to the single
685 // `value`; multiple specifiers are rendered against the same value.
686 // This matches libmagic's single-argument model -- magic rules only
687 // expose one read value per rule.
688 let out = format_magic_message("a=%d b=%d", &Value::Int(5), &long_t());
689 assert_eq!(out, "a=5 b=5");
690 }
691
692 #[test]
693 fn test_width_padding() {
694 // Zero-padded width with negative value: sign must precede zeros.
695 // Regression guard for sign-aware zero-padding (C printf semantics).
696 let out = format_magic_message("%05d", &Value::Int(-7), &long_t());
697 assert_eq!(out, "-0007");
698 let out = format_magic_message("%06d", &Value::Int(-42), &long_t());
699 assert_eq!(out, "-00042");
700 // Zero-padded width.
701 let out = format_magic_message("%05d", &Value::Int(42), &long_t());
702 assert_eq!(out, "00042");
703 // Space-padded width.
704 let out = format_magic_message("%5d", &Value::Int(42), &long_t());
705 assert_eq!(out, " 42");
706 // Negative with space-padding: sign stays in the body, spaces lead.
707 let out = format_magic_message("%5d", &Value::Int(-7), &long_t());
708 assert_eq!(out, " -7");
709 // Left-aligned (zero flag ignored when `-` is set).
710 let out = format_magic_message("%-5d|", &Value::Int(42), &long_t());
711 assert_eq!(out, "42 |");
712 // Left-aligned negative: body left-aligned, spaces trail.
713 let out = format_magic_message("%-6d|", &Value::Int(-7), &long_t());
714 assert_eq!(out, "-7 |");
715 }
716
717 #[test]
718 fn test_width_cap_prevents_large_allocation() {
719 // A width larger than MAX_FORMAT_WIDTH must be silently clamped.
720 // The output should be valid (the value rendered, possibly padded)
721 // rather than triggering a huge allocation.
722 let huge_width = format!("%{}d", usize::MAX);
723 let out = format_magic_message(&huge_width, &Value::Int(1), &long_t());
724 // After clamping, the output is at most MAX_FORMAT_WIDTH+1 chars.
725 assert!(
726 out.len() <= MAX_FORMAT_WIDTH + 1,
727 "output too long: {}",
728 out.len()
729 );
730 assert!(out.ends_with('1'), "rendered value must appear: {out:?}");
731 }
732
733 // ---- edge cases --------------------------------------------------
734
735 #[test]
736 fn test_empty_template() {
737 assert_eq!(
738 format_magic_message("", &Value::Uint(0), &byte_t()),
739 String::new()
740 );
741 }
742
743 #[test]
744 fn test_literal_with_no_specifiers() {
745 assert_eq!(
746 format_magic_message("hello world", &Value::Uint(0), &byte_t()),
747 "hello world"
748 );
749 }
750
751 #[test]
752 fn test_trailing_percent_with_no_spec() {
753 // A stray `%` at end-of-string: pass through literally.
754 let out = format_magic_message("done %", &Value::Uint(0), &byte_t());
755 assert_eq!(out, "done %");
756 }
757
758 #[test]
759 fn test_unknown_specifier_pass_through() {
760 // `%q` is not in our subset.
761 let out = format_magic_message("bad %q end", &Value::Uint(0), &byte_t());
762 assert_eq!(out, "bad %q end");
763 }
764
765 #[test]
766 fn test_type_mismatch_string_conv_on_uint_still_renders() {
767 // `%s` against an integer value -- GNU `file` renders the number
768 // as decimal; libmagic-rs matches that behavior via `render_string`.
769 let out = format_magic_message("v=%s", &Value::Uint(42), &byte_t());
770 assert_eq!(out, "v=42");
771 }
772
773 #[test]
774 fn test_type_mismatch_numeric_conv_on_string_passes_through() {
775 // `%d` against a string has no sensible coercion -> literal.
776 let out = format_magic_message(
777 "v=%d",
778 &Value::String("hi".to_string()),
779 &TypeKind::String {
780 max_length: None,
781 flags: StringFlags::default(),
782 },
783 );
784 assert_eq!(out, "v=%d");
785 }
786
787 #[test]
788 fn test_char_specifier_accepts_full_byte_range() {
789 // `%c` emits every byte value 0x00..=0xff directly, matching
790 // GNU `file` / C printf semantics. Bytes 0x80-0xff are embedded
791 // as their Latin-1 code points via `char::from(u8)`.
792 // 0xff maps to U+00FF ('ÿ'); UTF-8 encoding is 0xc3 0xbf.
793 let out = format_magic_message("[%c]", &Value::Uint(0xff), &byte_t());
794 assert_eq!(out, "[\u{00ff}]");
795
796 // ASCII boundary stays unchanged.
797 let out = format_magic_message("[%c]", &Value::Uint(u64::from(b'A')), &byte_t());
798 assert_eq!(out, "[A]");
799
800 // Out-of-range (doesn't fit u8) passes through literally.
801 let out = format_magic_message("[%c]", &Value::Uint(0x1_0000), &byte_t());
802 assert_eq!(out, "[%c]");
803 }
804
805 #[test]
806 fn test_byte_width_masking_on_negative_signed_byte() {
807 // Regression guard: a signed byte carrying -1 (the representation
808 // on the Value side is Int(-1)) must NOT render as a 64-bit mask.
809 let out = format_magic_message("%x", &Value::Int(-1), &byte_t());
810 assert_eq!(out, "ff");
811 }
812
813 #[test]
814 fn test_hex_width_masking_respects_16bit() {
815 let short_t = TypeKind::Short {
816 endian: crate::parser::ast::Endianness::Little,
817 signed: true,
818 };
819 let out = format_magic_message("%x", &Value::Int(-1), &short_t);
820 assert_eq!(out, "ffff");
821 }
822}