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