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}