fix(fatfs): fix a bug in SFN generation from LFN and rewrite to match C algorithm

Rewrite build_lfn_short_entry_name() and add _gen_numname_suffix() helper
to match the gen_numname() algorithm in ff.c. This fixes:

- chr(order) producing raw binary instead of ASCII digits
- Collision for order >= 10 when str(order) makes the name exceed 8 chars
- Hex suffix with dynamic stem shortening (matching C implementation)
- CRC16-CCITT hash for seq > 5 to reduce collision probability

Also fix LDIR_Name2_SIZE typo in long_filename_utils.py (should be
LDIR_Name3_SIZE), which made the assertion guard too permissive.

Add ShortFilenameGenerationTestCase with 9 unit tests covering single-digit,
multi-digit, hash-based, and collision-free generation scenarios.
This commit is contained in:
Adam Múdry
2026-04-13 15:59:48 +02:00
parent db22058940
commit abea28451f
4 changed files with 184 additions and 67 deletions
+1 -1
View File
@@ -208,7 +208,7 @@ class Directory:
# type: (Entry, str, str, Directory, int, int, DATETIME, DATETIME) -> Entry
lfn_full_name: str = build_lfn_full_name(name, extension)
lfn_unique_entry_order: int = build_lfn_unique_entry_name_order(target_dir.entities, name)
lfn_short_entry_name: str = build_lfn_short_entry_name(name, extension, lfn_unique_entry_order)
lfn_short_entry_name: str = build_lfn_short_entry_name(name, extension, lfn_unique_entry_order, lfn=lfn_full_name)
checksum: int = lfn_checksum(lfn_short_entry_name)
entries_count: int = get_required_lfn_entries_count(lfn_full_name)
@@ -53,7 +53,7 @@ def split_name_to_lfn_entry_blocks(name: str) -> List[bytes]:
Notice that since every character is coded using 2 bytes be must add 0x00 to ASCII symbols ('G' -> 'G\x00', etc.),
since character 'T' ends in the first block, we must add '\x00\x00' after 'T\x00'.
"""
max_entry_size: int = Entry.LDIR_Name1_SIZE + Entry.LDIR_Name2_SIZE + Entry.LDIR_Name2_SIZE
max_entry_size: int = Entry.LDIR_Name1_SIZE + Entry.LDIR_Name2_SIZE + Entry.LDIR_Name3_SIZE
assert len(name) <= max_entry_size
blocks_: List[bytes] = [
convert_to_utf16_and_pad(content=name[:Entry.LDIR_Name1_SIZE],
@@ -68,10 +68,11 @@ def split_name_to_lfn_entry_blocks(name: str) -> List[bytes]:
def build_lfn_unique_entry_name_order(entities: list, lfn_entry_name: str) -> int:
"""
The short entry contains only the first 6 characters of the file name,
and we have to distinguish it from other names within the directory starting with the same 6 characters.
To make it unique, we add its order in relation to other names such that lfn_entry_name[:6] == other[:6].
The order is specified by the character, starting with chr(1).
The short entry contains only the first characters of the file name plus a '~' suffix
with hexadecimal sequence number, matching the gen_numname() algorithm in ff.c.
For seq <= 5 the suffix is the decimal-looking hex digit (e.g. ~1 .. ~5).
For seq > 5 a CRC hash is used instead (handled by build_lfn_short_entry_name).
E.g. the file in directory 'thisisverylongfilenama.txt' will be named 'THISIS~1TXT' in its short entry.
If we add another file 'thisisverylongfilenamax.txt' its name in the short entry will be 'THISIS~2TXT'.
+40 -4
View File
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2021-2024 Espressif Systems (Shanghai) CO LTD
# SPDX-FileCopyrightText: 2021-2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
import argparse
import binascii
@@ -112,9 +112,45 @@ def right_strip_string(content: str, pad: int = PAD_CHAR) -> str:
return content.rstrip(chr(pad))
def build_lfn_short_entry_name(name: str, extension: str, order: int) -> str:
return '{}{}'.format(pad_string(content=name[:MAX_NAME_SIZE - 2] + '~' + chr(order), size=MAX_NAME_SIZE),
pad_string(extension[:MAX_EXT_SIZE], size=MAX_EXT_SIZE))
def _gen_numname_suffix(seq: int, lfn: str) -> str:
"""
Generate the numeric tail suffix for a short filename entry, matching
the logic of gen_numname() in ff.c.
For seq > 5, a CRC-based hash is computed from seq and the LFN to reduce
collision probability. The suffix is rendered as hexadecimal digits
(e.g. '~1', '~A', '~3F2') and always starts with '~'.
"""
if seq > 5:
# Hash path: CRC16-CCITT seeded with seq, fed with LFN characters
sreg = seq
for ch in lfn:
wc = ord(ch)
for _ in range(16):
sreg = (sreg << 1) + (wc & 1)
wc >>= 1
if sreg & 0x10000:
sreg ^= 0x11021
seq = sreg & 0xFFFF
# Convert seq to uppercase hexadecimal digits (no '0x' prefix)
hex_str = format(seq, 'X')
return '~' + hex_str
def build_lfn_short_entry_name(name: str, extension: str, order: int, lfn: str = '') -> str:
"""
Build the 8.3 short entry name for a long filename entry.
Mirrors gen_numname() from ff.c: the suffix ('~' + hex digits) is built
first, then the stem (beginning of the long name) is truncated to fit
within MAX_NAME_SIZE (8) characters together with the suffix.
"""
suffix = _gen_numname_suffix(order, lfn)
name_part = name[: MAX_NAME_SIZE - len(suffix)] + suffix
padded_name = pad_string(content=name_part, size=MAX_NAME_SIZE)
padded_ext = pad_string(extension[:MAX_EXT_SIZE], size=MAX_EXT_SIZE)
return f'{padded_name}{padded_ext}'
def lfn_checksum(short_entry_name: str) -> int: