Skip to main content

hadris_fat/
file.rs

1use core::fmt;
2
3use hadris_fixed::FixedBytes;
4
5/// A type representing a short filename (8.3 format)
6#[repr(transparent)]
7#[derive(Clone, Copy, PartialEq, Eq)]
8pub struct ShortFileName(FixedBytes<12>);
9
10impl fmt::Debug for ShortFileName {
11    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
12        let mut tuple = f.debug_tuple("ShortFileName");
13        // On-disk names are OEM-encoded; high bytes may not be valid UTF-8.
14        match self.0.try_as_str() {
15            Ok(name) => tuple.field(&name),
16            Err(_) => tuple.field(&self.0.as_bytes()),
17        };
18        tuple.finish()
19    }
20}
21
22#[derive(Debug)]
23/// Error returned when bytes cannot form a valid FAT 8.3 filename.
24pub struct CreateShortFileNameError;
25
26impl fmt::Display for CreateShortFileNameError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str("disallowed characters in short file name")
29    }
30}
31
32#[cfg(feature = "std")]
33impl std::error::Error for CreateShortFileNameError {}
34
35impl ShortFileName {
36    /// Punctuation permitted in a FAT short filename.
37    pub const ALLOWED_SYMBOLS: &'static [u8] = b"$%'-_@~`!(){}^#&";
38
39    /// Creates a short filename from its space-padded 11-byte directory form.
40    pub fn new(bytes: [u8; 11]) -> Result<Self, CreateShortFileNameError> {
41        // Special case: "." and ".." directory entries
42        if bytes == *b".          " {
43            let mut name = FixedBytes::empty();
44            name.push_byte(b'.');
45            return Ok(Self(name));
46        }
47        if bytes == *b"..         " {
48            let mut name = FixedBytes::empty();
49            name.push_slice(b"..");
50            return Ok(Self(name));
51        }
52
53        for byte in &bytes {
54            if byte.is_ascii_uppercase()
55                || Self::ALLOWED_SYMBOLS.contains(byte)
56                || byte.is_ascii_digit()
57                || *byte == b' '
58                || *byte > 127
59            {
60                continue;
61            }
62            return Err(CreateShortFileNameError);
63        }
64
65        let mut name = FixedBytes::empty();
66        name.push_slice(&bytes[0..8]);
67        name.push_byte(b'.');
68        name.push_slice(&bytes[8..11]);
69        Ok(Self(name))
70    }
71
72    /// Get the raw 11-byte name for checksum calculation.
73    ///
74    /// Operates on the underlying byte buffer directly — does NOT go through
75    /// [`Self::as_str`], which would panic on non-ASCII OEM bytes (e.g.
76    /// CP437 0x82 for `é`). The 11-byte form is what's stored in the FAT
77    /// directory entry, so byte-level access is the correct level here
78    /// regardless of encoding.
79    pub fn raw_bytes(&self) -> [u8; 11] {
80        let bytes = self.0.as_bytes();
81        let mut result = [b' '; 11];
82        // Stored layout: "BASE    .EXT" — at most 8 base + dot + 3 ext.
83        let dot_pos = bytes.iter().position(|&b| b == b'.').unwrap_or(bytes.len());
84        let name_len = dot_pos.min(8);
85        result[..name_len].copy_from_slice(&bytes[..name_len]);
86        if dot_pos < bytes.len() {
87            let ext_start = dot_pos + 1;
88            let ext_len = (bytes.len() - ext_start).min(3);
89            result[8..8 + ext_len].copy_from_slice(&bytes[ext_start..ext_start + ext_len]);
90        }
91        result
92    }
93
94    /// Returns the formatted short filename as a string.
95    ///
96    /// # Panics
97    /// Panics if the name is not valid UTF-8 (possible with OEM high bytes on
98    /// untrusted images). Use [`Self::try_as_str`] on untrusted images, or
99    /// [`FileEntry::name`] / [`Self::matches`], which tolerate such names.
100    ///
101    /// [`FileEntry::name`]: crate::dir::FileEntry::name
102    pub fn as_str(&self) -> &str {
103        self.0.as_str()
104    }
105
106    /// Returns the formatted short filename, or an error if it is not valid
107    /// UTF-8 (possible with OEM high bytes on untrusted images).
108    pub fn try_as_str(&self) -> Result<&str, core::str::Utf8Error> {
109        self.0.try_as_str()
110    }
111
112    /// The stored `BASE    .EXT` bytes without UTF-8 validation — short names
113    /// are OEM-encoded, so high bytes are not necessarily valid UTF-8.
114    #[cfg(feature = "alloc")]
115    pub(crate) fn as_padded_bytes(&self) -> &[u8] {
116        self.0.as_bytes()
117    }
118
119    /// Returns a copy of this 8.3 name with the base and/or extension
120    /// lowercased according to the Windows NT `DIR_NTRes` case flags.
121    ///
122    /// FAT stores 8.3 names uppercase on disk; the case flags (see
123    /// [`NtCaseFlags`](crate::raw::NtCaseFlags)) record that the name was
124    /// originally entered lowercase so it can be presented that way without a
125    /// long-file-name entry. Only ASCII letters are re-cased; any other bytes
126    /// (OEM high bytes, digits, symbols, the `.` separator) pass through
127    /// unchanged.
128    pub fn with_nt_case(&self, flags: crate::raw::NtCaseFlags) -> ShortFileName {
129        use crate::raw::NtCaseFlags;
130
131        fn push_maybe_lower(out: &mut FixedBytes<12>, part: &[u8], lower: bool) {
132            for &byte in part {
133                out.push_byte(if lower {
134                    byte.to_ascii_lowercase()
135                } else {
136                    byte
137                });
138            }
139        }
140
141        let bytes = self.0.as_bytes();
142        let dot = bytes.iter().position(|&b| b == b'.');
143        let base_end = dot.unwrap_or(bytes.len());
144        let mut out = FixedBytes::<12>::empty();
145        push_maybe_lower(
146            &mut out,
147            &bytes[..base_end],
148            flags.contains(NtCaseFlags::LOWER_BASE),
149        );
150        if let Some(dot) = dot {
151            out.push_byte(b'.');
152            push_maybe_lower(
153                &mut out,
154                &bytes[dot + 1..],
155                flags.contains(NtCaseFlags::LOWER_EXT),
156            );
157        }
158        ShortFileName(out)
159    }
160
161    /// Check if this short filename matches a given name (case-insensitive).
162    /// Handles both padded ("TEST    .TXT") and unpadded ("TEST.TXT") formats.
163    ///
164    /// Operates on raw bytes: OEM high bytes in untrusted on-disk names are
165    /// not necessarily valid UTF-8, and ASCII case comparison is well-defined
166    /// on bytes.
167    pub fn matches(&self, name: &str) -> bool {
168        fn trim_end_spaces(mut bytes: &[u8]) -> &[u8] {
169            while let [rest @ .., b' '] = bytes {
170                bytes = rest;
171            }
172            bytes
173        }
174
175        let raw = self.0.as_bytes();
176        // Stored format: "BASE    .EXT"
177        let (our_base, our_ext) = match raw.iter().position(|&b| b == b'.') {
178            Some(dot) => (&raw[..dot], &raw[dot + 1..]),
179            None => (raw, &[][..]),
180        };
181        let (our_base, our_ext) = (trim_end_spaces(our_base), trim_end_spaces(our_ext));
182
183        let name = name.as_bytes();
184        let (search_base, search_ext) = match name.iter().rposition(|&b| b == b'.') {
185            Some(dot) => (&name[..dot], &name[dot + 1..]),
186            None => (name, &[][..]),
187        };
188
189        our_base.eq_ignore_ascii_case(search_base) && our_ext.eq_ignore_ascii_case(search_ext)
190    }
191
192    /// Calculate the LFN checksum for this short filename.
193    /// This is used to validate that LFN entries belong to this short name entry.
194    pub fn lfn_checksum(&self) -> u8 {
195        let name = self.raw_bytes();
196        let mut sum: u8 = 0;
197        for &byte in &name {
198            // Rotate right and add
199            sum = sum.rotate_right(1).wrapping_add(byte);
200        }
201        sum
202    }
203
204    /// Convert back to the raw 11-byte format for directory entries.
205    #[cfg(feature = "write")]
206    pub fn to_raw_bytes(&self) -> [u8; 11] {
207        self.raw_bytes()
208    }
209
210    /// Generate an 8.3 short filename from a long name.
211    ///
212    /// Rules:
213    /// - Uppercase all ASCII letters
214    /// - Strip invalid characters, replace with `_`
215    /// - Base name max 8 chars, extension max 3 chars
216    /// - Add `~N` suffix for collisions (caller should increment suffix)
217    ///
218    /// Non-ASCII characters always become `_`. To preserve OEM-encoded Latin
219    /// characters (e.g. `é` → CP437 0x82), use [`from_long_name_with`].
220    ///
221    /// [`from_long_name_with`]: Self::from_long_name_with
222    #[cfg(feature = "write")]
223    pub fn from_long_name(name: &str, suffix: u8) -> Result<Self, CreateShortFileNameError> {
224        Self::from_long_name_with(name, suffix, &crate::oem::LossyAsciiOemCpConverter)
225    }
226
227    /// Like [`from_long_name`](Self::from_long_name), but routes non-ASCII
228    /// characters through the supplied [`OemCpConverter`](crate::oem::OemCpConverter) rather than
229    /// dropping them to `_`.
230    ///
231    /// Characters the converter cannot encode still become `_`.
232    #[cfg(feature = "write")]
233    pub fn from_long_name_with(
234        name: &str,
235        suffix: u8,
236        oem: &dyn crate::oem::OemCpConverter,
237    ) -> Result<Self, CreateShortFileNameError> {
238        // Find the last dot for extension separation
239        let (base, ext) = match name.rfind('.') {
240            Some(pos) if pos > 0 => (&name[..pos], &name[pos + 1..]),
241            _ => (name, ""),
242        };
243
244        // Process base name: uppercase, strip invalid chars
245        let mut base_chars = [b' '; 8];
246        let mut base_len = 0;
247        for ch in base.chars() {
248            if base_len >= 6 && suffix > 0 {
249                // Leave room for ~N suffix
250                break;
251            }
252            if base_len >= 8 {
253                break;
254            }
255            let processed = Self::process_char(ch, oem);
256            if processed != 0 {
257                base_chars[base_len] = processed;
258                base_len += 1;
259            }
260        }
261
262        // Add ~N suffix if needed (Microsoft-style collision handling)
263        if suffix > 0 {
264            if suffix <= 4 {
265                // For N=1..4: use simple ~N suffix (e.g., FILENA~1)
266                let max_base = 6; // leave room for ~N (2 chars)
267                if base_len > max_base {
268                    base_len = max_base;
269                }
270                base_chars[base_len] = b'~';
271                base_len += 1;
272                base_chars[base_len] = b'0' + suffix;
273                base_len += 1;
274            } else {
275                // For N>4: use hash-based suffix ~HHHH where HHHH is a 4-char
276                // hex hash derived from the long name + suffix, per Microsoft's
277                // recommended approach for reducing collisions.
278                let hash = Self::lfn_hash(name, suffix);
279                let max_base = 2; // leave room for ~HHHH (5 chars) + at least 2 base chars
280                if base_len > max_base {
281                    base_len = max_base;
282                }
283                base_chars[base_len] = b'~';
284                base_len += 1;
285                // Write 4 hex digits
286                for i in (0..4).rev() {
287                    let nibble = ((hash >> (i * 4)) & 0xF) as u8;
288                    base_chars[base_len] = if nibble < 10 {
289                        b'0' + nibble
290                    } else {
291                        b'A' + nibble - 10
292                    };
293                    base_len += 1;
294                }
295            }
296        }
297
298        // Process extension: uppercase, strip invalid chars
299        let mut ext_chars = [b' '; 3];
300        let mut ext_len = 0;
301        for ch in ext.chars() {
302            if ext_len >= 3 {
303                break;
304            }
305            let processed = Self::process_char(ch, oem);
306            if processed != 0 {
307                ext_chars[ext_len] = processed;
308                ext_len += 1;
309            }
310        }
311
312        // Combine into 11-byte name
313        let mut result = [b' '; 11];
314        result[..8].copy_from_slice(&base_chars);
315        result[8..11].copy_from_slice(&ext_chars);
316
317        // Validate we have at least one character
318        if base_len == 0 && ext_len == 0 {
319            return Err(CreateShortFileNameError);
320        }
321
322        Self::new(result)
323    }
324
325    /// Compute a simple hash from a long filename and suffix for short name generation.
326    /// Returns a 16-bit value used as a 4-hex-digit suffix.
327    #[cfg(feature = "write")]
328    fn lfn_hash(name: &str, suffix: u8) -> u16 {
329        let mut hash: u16 = suffix as u16;
330        for &b in name.as_bytes() {
331            hash = hash.wrapping_mul(37).wrapping_add(b as u16);
332        }
333        hash
334    }
335
336    /// Process a character for short filename conversion.
337    /// Returns 0 if the character should be skipped.
338    ///
339    /// Non-ASCII characters are routed through `oem.encode`; if the converter
340    /// returns `None`, the byte falls back to `_`.
341    #[cfg(feature = "write")]
342    fn process_char(ch: char, oem: &dyn crate::oem::OemCpConverter) -> u8 {
343        if ch.is_ascii_alphanumeric() {
344            ch.to_ascii_uppercase() as u8
345        } else if Self::ALLOWED_SYMBOLS.contains(&(ch as u8)) {
346            ch as u8
347        } else if ch == ' ' || ch == '.' {
348            // Skip spaces and extra dots (dots are handled separately)
349            0
350        } else if ch.is_ascii() {
351            // Replace other ASCII chars with underscore
352            b'_'
353        } else {
354            // Non-ASCII: ask the OEM converter for a byte; fall back to '_'.
355            oem.encode(ch).unwrap_or(b'_')
356        }
357    }
358}
359
360/// Maximum number of UTF-16 code units in a long filename (per the FAT LFN spec).
361pub const LFN_MAX_UTF16_UNITS: usize = 255;
362
363/// A Long File Name stored as UTF-16.
364///
365/// LFN entries on disk encode the filename in UTF-16LE. Storing the data in its
366/// native form avoids two classes of bugs that previously lived here (see issue
367/// #28): the buffer never holds invalid UTF-8, and surrogate pairs (characters
368/// outside the Basic Multilingual Plane, e.g. emoji) are preserved correctly
369/// regardless of how the pair lands across LFN entry boundaries — the conversion
370/// to scalar values happens once, at access time.
371#[cfg(feature = "lfn")]
372#[derive(Clone, PartialEq, Eq)]
373pub struct LongFileName {
374    chars: [u16; LFN_MAX_UTF16_UNITS],
375    len: usize,
376}
377
378#[cfg(feature = "lfn")]
379impl fmt::Debug for LongFileName {
380    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
381        struct LossyChars<'a>(&'a LongFileName);
382        impl fmt::Debug for LossyChars<'_> {
383            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384                f.write_str("\"")?;
385                for ch in self.0.chars() {
386                    fmt::Write::write_char(f, ch)?;
387                }
388                f.write_str("\"")
389            }
390        }
391        f.debug_tuple("LongFileName")
392            .field(&LossyChars(self))
393            .finish()
394    }
395}
396
397#[cfg(feature = "lfn")]
398impl Default for LongFileName {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404#[cfg(feature = "lfn")]
405impl LongFileName {
406    /// Number of UTF-16 code units stored per LFN directory entry
407    pub const CHARS_PER_ENTRY: usize = 13;
408
409    /// Create a new empty LongFileName
410    pub fn new() -> Self {
411        Self {
412            chars: [0; LFN_MAX_UTF16_UNITS],
413            len: 0,
414        }
415    }
416
417    /// Clear the filename
418    pub fn clear(&mut self) {
419        self.len = 0;
420    }
421
422    /// Check if the filename is empty
423    pub fn is_empty(&self) -> bool {
424        self.len == 0
425    }
426
427    /// Number of UTF-16 code units in the filename.
428    pub fn len(&self) -> usize {
429        self.len
430    }
431
432    /// Prepend UTF-16LE characters from an LFN entry.
433    /// LFN entries are stored in reverse order, so we prepend.
434    /// Characters are: 5 from name1, 6 from name2, 2 from name3.
435    pub fn prepend_lfn_entry(&mut self, name1: &[u8; 10], name2: &[u8; 12], name3: &[u8; 4]) {
436        // Collect all 13 UTF-16LE code units
437        let mut utf16_chars = [0u16; Self::CHARS_PER_ENTRY];
438
439        // name1: 5 UTF-16LE characters (10 bytes)
440        for i in 0..5 {
441            utf16_chars[i] = u16::from_le_bytes([name1[i * 2], name1[i * 2 + 1]]);
442        }
443        // name2: 6 UTF-16LE characters (12 bytes)
444        for i in 0..6 {
445            utf16_chars[5 + i] = u16::from_le_bytes([name2[i * 2], name2[i * 2 + 1]]);
446        }
447        // name3: 2 UTF-16LE characters (4 bytes)
448        for i in 0..2 {
449            utf16_chars[11 + i] = u16::from_le_bytes([name3[i * 2], name3[i * 2 + 1]]);
450        }
451
452        // Find end of actual characters (0x0000 or 0xFFFF marks padding)
453        let actual_len = utf16_chars
454            .iter()
455            .position(|&c| c == 0x0000 || c == 0xFFFF)
456            .unwrap_or(Self::CHARS_PER_ENTRY);
457
458        // Prepend code units to the existing buffer.
459        let new_len = self.len + actual_len;
460        if new_len > LFN_MAX_UTF16_UNITS {
461            // Spec violation: silently drop the entry rather than panicking on
462            // a malformed image. Matches the prior behavior.
463            return;
464        }
465        if self.len > 0 {
466            self.chars.copy_within(0..self.len, actual_len);
467        }
468        self.chars[..actual_len].copy_from_slice(&utf16_chars[..actual_len]);
469        self.len = new_len;
470    }
471
472    /// Borrow the filename as raw UTF-16 code units.
473    pub fn as_utf16(&self) -> &[u16] {
474        &self.chars[..self.len]
475    }
476
477    /// Iterate over the decoded scalar values of the filename.
478    ///
479    /// Lone surrogates (which the spec disallows but a malformed image could
480    /// contain) are reported as [`char::REPLACEMENT_CHARACTER`] (U+FFFD).
481    pub fn chars(&self) -> impl Iterator<Item = char> + '_ {
482        char::decode_utf16(self.chars[..self.len].iter().copied())
483            .map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER))
484    }
485
486    /// Compare the filename to a `&str` without allocating.
487    pub fn eq_str(&self, s: &str) -> bool {
488        self.chars().eq(s.chars())
489    }
490
491    /// Encode `name` as UTF-16LE into the buffer. Returns `None` if the name
492    /// exceeds [`LFN_MAX_UTF16_UNITS`] (the FAT spec cap for LFN names).
493    /// Used by the write path to remember the name in-memory after creating
494    /// a file; the chars stay in UTF-16 so the disk-side LFN write can pull
495    /// them out without a second UTF-8 conversion.
496    #[cfg(feature = "write")]
497    pub fn from_str_utf16(name: &str) -> Option<Self> {
498        let mut out = Self::new();
499        for ch in name.chars() {
500            let mut tmp = [0u16; 2];
501            for &c in ch.encode_utf16(&mut tmp).iter() {
502                if out.len >= LFN_MAX_UTF16_UNITS {
503                    return None;
504                }
505                out.chars[out.len] = c;
506                out.len += 1;
507            }
508        }
509        Some(out)
510    }
511}
512
513#[cfg(feature = "lfn")]
514impl fmt::Display for LongFileName {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        for ch in self.chars() {
517            fmt::Write::write_char(f, ch)?;
518        }
519        Ok(())
520    }
521}
522
523/// Builder for accumulating LFN entries while iterating
524#[cfg(feature = "lfn")]
525pub struct LfnBuilder {
526    /// The accumulated long filename
527    pub name: LongFileName,
528    /// Expected checksum (from the short name entry)
529    pub checksum: u8,
530    /// The sequence number we're expecting next (counting down from last entry)
531    pub expected_seq: u8,
532    /// Whether we're currently building an LFN
533    pub building: bool,
534}
535
536#[cfg(feature = "lfn")]
537impl Default for LfnBuilder {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543#[cfg(feature = "lfn")]
544impl LfnBuilder {
545    /// Bit mask for the last LFN entry marker
546    pub const LAST_ENTRY_MASK: u8 = 0x40;
547    /// Mask for the sequence number (bits 0-5)
548    pub const SEQ_NUMBER_MASK: u8 = 0x3F;
549
550    /// Creates an empty long-file-name sequence builder.
551    pub fn new() -> Self {
552        Self {
553            name: LongFileName::new(),
554            checksum: 0,
555            expected_seq: 0,
556            building: false,
557        }
558    }
559
560    /// Reset the builder state
561    pub fn reset(&mut self) {
562        self.name.clear();
563        self.checksum = 0;
564        self.expected_seq = 0;
565        self.building = false;
566    }
567
568    /// Start building a new LFN from the first (last physical) entry
569    pub fn start(&mut self, seq_number: u8, checksum: u8) {
570        self.reset();
571        // The sequence number indicates how many entries there are; a count
572        // of zero is invalid and would underflow the countdown in add_entry.
573        let count = seq_number & Self::SEQ_NUMBER_MASK;
574        if count == 0 {
575            return;
576        }
577        self.building = true;
578        self.checksum = checksum;
579        self.expected_seq = count;
580    }
581
582    /// Add an LFN entry to the builder.
583    /// Returns true if the entry was accepted, false if there was a sequence error.
584    pub fn add_entry(
585        &mut self,
586        seq_number: u8,
587        checksum: u8,
588        name1: &[u8; 10],
589        name2: &[u8; 12],
590        name3: &[u8; 4],
591    ) -> bool {
592        let seq = seq_number & Self::SEQ_NUMBER_MASK;
593
594        // Check sequence number; zero is not a valid LFN sequence count
595        if seq == 0 || seq != self.expected_seq {
596            self.reset();
597            return false;
598        }
599
600        // Check checksum consistency
601        if checksum != self.checksum {
602            self.reset();
603            return false;
604        }
605
606        // Add the characters
607        self.name.prepend_lfn_entry(name1, name2, name3);
608
609        // Decrement expected sequence for next entry
610        self.expected_seq -= 1;
611
612        true
613    }
614
615    /// Check if we've received all LFN entries (ready for the short name entry)
616    pub fn is_complete(&self) -> bool {
617        self.building && self.expected_seq == 0
618    }
619
620    /// Validate the checksum against a short name and take the built LFN
621    pub fn finish(&mut self, short_name: &ShortFileName) -> Option<LongFileName> {
622        if !self.is_complete() {
623            self.reset();
624            return None;
625        }
626
627        // Validate checksum
628        if short_name.lfn_checksum() != self.checksum {
629            self.reset();
630            return None;
631        }
632
633        let result = core::mem::take(&mut self.name);
634        self.reset();
635        Some(result)
636    }
637}
638
639#[cfg(all(test, feature = "lfn", feature = "alloc"))]
640mod lfn_unicode_tests {
641    use super::*;
642    extern crate alloc;
643    use alloc::string::ToString;
644
645    /// Regression test for issue #28: lone surrogates in an LFN entry must
646    /// not produce undefined behavior. Previously, the encoder produced
647    /// invalid UTF-8 from lone surrogates and `as_str` then transmuted those
648    /// bytes via `from_utf8_unchecked`. With UTF-16 storage, lone surrogates
649    /// are surfaced as the replacement character (U+FFFD) instead.
650    #[test]
651    fn lone_high_surrogate_becomes_replacement_char() {
652        let mut lfn = LongFileName::new();
653        // Lone high surrogate 0xD800 followed by ASCII 'a'.
654        let name1: [u8; 10] = [0x00, 0xD8, b'a', 0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF];
655        let name2: [u8; 12] = [0xFF; 12];
656        let name3: [u8; 4] = [0xFF; 4];
657
658        lfn.prepend_lfn_entry(&name1, &name2, &name3);
659
660        let s = lfn.to_string();
661        assert_eq!(s, "\u{FFFD}a");
662    }
663
664    /// Regression test for issue #28: a valid surrogate pair encodes a
665    /// supplementary-plane character (here, U+1F600 GRINNING FACE — emoji).
666    /// Previously the encoder dropped the surrogate semantics and emitted two
667    /// 3-byte sequences that are invalid UTF-8.
668    #[test]
669    fn valid_surrogate_pair_decodes_to_supplementary_codepoint() {
670        let mut lfn = LongFileName::new();
671        // U+1F600 = 0xD83D 0xDE00 in UTF-16LE.
672        let name1: [u8; 10] = [0x3D, 0xD8, 0x00, 0xDE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF];
673        let name2: [u8; 12] = [0xFF; 12];
674        let name3: [u8; 4] = [0xFF; 4];
675
676        lfn.prepend_lfn_entry(&name1, &name2, &name3);
677
678        assert_eq!(lfn.to_string(), "\u{1F600}");
679    }
680
681    /// A surrogate pair split across two LFN entries (high in the earlier
682    /// entry, low in the later one) must still decode correctly. The on-disk
683    /// order is reverse, so the entry containing the LOW surrogate is read
684    /// first (prepended first), then the entry containing the HIGH surrogate
685    /// is prepended in front.
686    #[test]
687    fn surrogate_pair_split_across_entries() {
688        let mut lfn = LongFileName::new();
689
690        // Second-prepended entry (logically earlier in the filename): ends
691        // with the high surrogate of U+1F600.
692        let high_name1: [u8; 10] = [b'a', 0, b'b', 0, b'c', 0, b'd', 0, b'e', 0];
693        let high_name2: [u8; 12] = [b'f', 0, b'g', 0, b'h', 0, b'i', 0, b'j', 0, b'k', 0];
694        let high_name3: [u8; 4] = [b'l', 0, 0x3D, 0xD8]; // 0xD83D = high surrogate
695
696        // First-prepended entry (logically later): starts with the low
697        // surrogate of U+1F600.
698        let low_name1: [u8; 10] = [
699            0x00, 0xDE, // 0xDE00 = low surrogate
700            b'm', 0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
701        ];
702        let low_name2: [u8; 12] = [0xFF; 12];
703        let low_name3: [u8; 4] = [0xFF; 4];
704
705        lfn.prepend_lfn_entry(&low_name1, &low_name2, &low_name3);
706        lfn.prepend_lfn_entry(&high_name1, &high_name2, &high_name3);
707
708        assert_eq!(lfn.to_string(), "abcdefghijkl\u{1F600}m");
709    }
710
711    /// Two-byte UTF-8 path: a code point in the 0x80..0x800 range must round
712    /// through as one character.
713    #[test]
714    fn two_byte_utf8_codepoint() {
715        let mut lfn = LongFileName::new();
716        // U+00E9 (é)
717        let name1: [u8; 10] = [0xE9, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
718        let name2: [u8; 12] = [0xFF; 12];
719        let name3: [u8; 4] = [0xFF; 4];
720
721        lfn.prepend_lfn_entry(&name1, &name2, &name3);
722
723        assert_eq!(lfn.to_string(), "é");
724    }
725
726    /// Verify `eq_str` works without allocation against decoded characters.
727    #[test]
728    fn eq_str_matches_decoded_chars() {
729        let mut lfn = LongFileName::new();
730        // U+1F600
731        let name1: [u8; 10] = [0x3D, 0xD8, 0x00, 0xDE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF];
732        let name2: [u8; 12] = [0xFF; 12];
733        let name3: [u8; 4] = [0xFF; 4];
734
735        lfn.prepend_lfn_entry(&name1, &name2, &name3);
736
737        assert!(lfn.eq_str("\u{1F600}"));
738        assert!(!lfn.eq_str("X"));
739    }
740
741    /// Regression test: an LFN entry with the last-entry flag set but a
742    /// sequence count of zero is invalid; starting a sequence with it would
743    /// underflow the countdown in `add_entry` (fuzz-found panic).
744    #[test]
745    fn lfn_start_rejects_zero_sequence_count() {
746        let mut builder = LfnBuilder::new();
747        builder.start(0x40, 0);
748        assert!(!builder.building);
749        assert!(!builder.add_entry(0x40, 0, &[0; 10], &[0; 12], &[0; 4]));
750    }
751}