Skip to content
FreeDOSDeep Dive Published Updated 11 min readViews unavailable

FreeDOS 2046 VFAT Forensics: Validate Long-Name Slot Chains

Reconstruct raw VFAT directory names, validate ordinals and every short-alias checksum, and audit the FreeDOS 2046 reader without modifying evidence.

A FAT long filename is not one directory record. It is a run of VFAT long-name slots immediately followed by the ordinary short-name entry that supplies the file’s metadata and compatibility alias. A parser that joins nearby printable text without validating that relationship can assign one file’s name to another entry, especially after a legacy rename, interrupted update, or damaged directory chain.

This is a disk-format and source-review guide, not a DOSLFN installation tutorial or an INT 21h application API reference. It treats directory records as evidence and shows how to validate a bounded slot group before displaying a long name. The Microsoft FAT specification is version 1.03, published in 2000; implementation observations below are pinned to the FreeDOS Kernel 2046 source commit 5ffb5502d39a10a30f5b8a9e8beeba0bf30245d3. Do not silently generalize that one source revision to every FreeDOS package, build option, driver, or DOS-compatible kernel.

Preserve the directory before interpreting it

If the name matters to an incident or recovery, work from a sector image or a verified read-only copy. Record the device identity, partition start, image hash, filesystem type, and the directory location from which each 32-byte record was read. A directory entry’s logical position is useful evidence: the long-name slots must be contiguous with their associated short entry. A tool that sorts records, decodes them into display strings, or repairs the directory before capture can discard that evidence.

Walk the directory through validated FAT cluster chains; do not guess physical offsets from a displayed path. FAT12 and FAT16 root directories have a fixed region, while a FAT32 root is itself a cluster chain. Those traversal rules are covered in the FAT internals guide. This article begins only after the analyst has acquired the exact raw 32-byte records in directory order.

Before interpreting an entry, handle its first byte. 0x00 marks the end of the allocated directory entries, so later slots in that directory are not active records. 0xE5 marks a deleted entry. Do not treat either marker as a valid LFN ordinal or as an invitation to attach an old name to the next live short entry. Keep deleted records in a separate carving workflow; they are not active members of a valid chain.

The on-disk relationship

Every LFN slot is a 32-byte directory entry with the long-name attribute value 0x0F. Its 13 UTF-16 code units are split across three non-contiguous byte ranges: five units at offsets 1-10, six at 14-25, and two at 28-31. Offset 0 holds the sequence ordinal and final-slot flag; offset 13 holds an 8-bit checksum of the following short entry’s 11 raw alias bytes. The short entry follows the entire LFN run and remains the source for ordinary directory metadata such as attributes, size, timestamps, and starting cluster.

The records appear in reverse ordinal order on disk. If a name takes three slots, the physical sequence immediately before its short entry is 0x43, 0x02, 0x01; the bit-6 0x40 flag in the first value marks the final LFN slot. The text itself is reconstructed in ordinal order 1, 2, 3. This distinction matters: taking the slots in physical order produces a reversed or scrambled name, while sorting bytes as text can conceal a missing or repeated ordinal.

FreeDOS Kernel 2046 defines this structure in hdr/fat.h: three name arrays, an attribute, an ordinal, a checksum, and fields corresponding to the reserved/type and first-cluster positions. Its writer in kernel/lfnapi.c creates the short entry and then writes the LFN pieces while moving backward through the directory. The source declares 13 code units per slot. These references establish the behavior of that pinned source, not the configuration of an arbitrary installed system.

Validate the complete group, not just readable characters

For a candidate run of N slots, validate all of the following before accepting the displayed name:

  1. The slots are contiguous and the next record is a live short entry, not another LFN slot, a deleted/end marker, or a volume-label record.
  2. The first physical slot contains the final-slot flag and ordinal N; the remaining records have ordinals N-1 down through 1, with no gap, duplicate, or extra slot. A 255-code-unit maximum needs no more than 20 slots because each holds 13 units.
  3. Each record has the LFN attribute, and every slot’s checksum matches the checksum recomputed from the short entry’s 11 raw name bytes. The checksum is an 8-bit association check, not a cryptographic digest or proof that the name is authentic.
  4. Reassemble the 13-unit fragments in ordinal order. A name shorter than the available slots is terminated by 0x0000 and padded afterward with 0xFFFF; if the character count exactly fills a multiple of 13 units, the name has no terminator or padding. Reject data that exceeds the 255-unit limit, has contradictory termination/padding, or cannot be decoded under the chosen Unicode policy.

Do not over-interpret fields that the specification marks reserved. Microsoft’s validation guidance says not to declare a record corrupt merely because reserved bytes are nonzero, and not to rewrite those bytes to zero. Preserve them in the evidence record. The pinned FreeDOS reader does not use the type or low cluster field to associate the name. A validator should therefore focus on defined association fields and record boundaries rather than inventing a zero-only rule that can reject forward-compatible data.

Recompute the short-name checksum

The specification rotates the running 8-bit value right by one bit, then adds each of the eleven alias bytes, keeping the result modulo 256. The input is the short entry’s on-disk 8-character base plus 3-character extension, including any space padding. Do not checksum a prettified alias with spaces trimmed, periods inserted, or case transformed.

def short_name_checksum(alias11: bytes) -> int:
    """Return the VFAT checksum for the raw 11-byte short-name field."""
    if len(alias11) != 11:
        raise ValueError("short-name field must contain exactly 11 bytes")

    value = 0
    for byte in alias11:
        rotated = (0x80 if value & 1 else 0) + (value >> 1)
        value = (rotated + byte) & 0xFF
    return value

Because this is only eight bits, different aliases can have the same checksum. A match is a format-required consistency check, not a unique file identity, authentication tag, or safe basis for repairing a damaged directory. Retain the raw alias, its directory offset, all LFN bytes, and the checksum result together.

What the pinned FreeDOS 2046 reader validates

The FreeDOS release page labels Kernel 2046 as build 2046 / version 2.46 and dates the release June 29, 2026. In the pinned lfn_dir_read implementation, the reader scans for a candidate non-LFN directory record, then scans backward through adjacent LFN entries. Its candidate filter excludes entries marked deleted and records whose attribute is exactly D_LFN; it does not explicitly reject a 0x00 end marker (dir_read returns 0 there, but lfn_dir_read ignores that return), volume labels, or invalid short-entry attribute combinations. It rejects an ordinal above 20, checks expected ordinal progression, and checks the short-name checksum in the continuation-slot branch. When it reaches the slot carrying the final-slot flag, that branch checks the ordinal/flag relationship and exits before the checksum comparison used for the other slots.

For a one-slot name, the only LFN record carries that flag, so this function does not compare that record’s checksum. For multi-slot names, the flagged highest-ordinal slot is likewise the one checksum not compared in this loop. The specification still requires the checksum in every member of the set to agree with the associated short name.

That is a source-level observation for the pinned commit, not proof of an exploitable defect or a statement about every build. The Microsoft specification says the checksum is stored in every long slot and that all values in a valid set agree with the short name. An independent evidence parser should apply that rule to every slot, including the flagged first physical record, and report any discrepancy. Do not infer from a successful DIR, an LFN API response, or one kernel source path that every on-disk slot passed an identical validation policy.

The distinction is useful during recovery: if any ordinal, adjacency, or checksum test fails, preserve the candidate group as an orphan or malformed run and report the adjacent short alias separately. Do not silently attach fragments based only on adjacency plus plausible Unicode. Do not rewrite the alias or slots to make the group pass. A repair changes directory evidence and may also affect how older 8.3-only tools see the file.

A bounded read-only validator for an already extracted group

The following Python example accepts the LFN records in physical directory order and the immediately following 32-byte short entry; the caller must establish their physical contiguity, order, and adjacency before calling it. It checks the complete ordinal run, attributes, every alias checksum, the UTF-16 field ordering, termination, padding, and length before returning a name. It deliberately ignores the type and cluster fields instead of forcing them to zero. It does not read sectors, follow FAT chains, recover deleted entries, translate the short alias through an OEM code page, or change media.

import struct

LFN_ATTR_MASK = 0x3F
LFN_ATTR = 0x0F
MAX_NAME_UNITS = 255
UNITS_PER_SLOT = 13


def checksum(alias11):
    if len(alias11) != 11:
        raise ValueError("expected an 11-byte short alias")
    total = 0
    for byte in alias11:
        total = ((0x80 if total & 1 else 0) + (total >> 1) + byte) & 0xFF
    return total


def slot_units(slot):
    if len(slot) != 32:
        raise ValueError("directory records must be 32 bytes")
    raw = slot[1:11] + slot[14:26] + slot[28:32]
    return struct.unpack("<13H", raw)


def decode_lfn_group(slots_on_disk, short_entry):
    if len(short_entry) != 32 or short_entry[0] in (0x00, 0xE5):
        raise ValueError("missing live short entry")
    if (short_entry[11] & LFN_ATTR_MASK) == LFN_ATTR or short_entry[11] & 0x08:
        raise ValueError("the following record is not a file/directory alias")
    if not slots_on_disk:
        raise ValueError("empty LFN group")
    if any(len(slot) != 32 for slot in slots_on_disk):
        raise ValueError("directory records must be 32 bytes")

    first_id = slots_on_disk[0][0]
    count = first_id & 0x1F
    if not first_id & 0x40 or not 1 <= count <= 20 or len(slots_on_disk) != count:
        raise ValueError("invalid final-slot marker or group length")

    expected_checksum = checksum(short_entry[:11])
    ordered = []
    for index, slot in enumerate(slots_on_disk):
        ordinal = count - index
        expected_id = ordinal | (0x40 if index == 0 else 0)
        if slot[0] != expected_id:
            raise ValueError("missing, duplicated, or out-of-order ordinal")
        if (slot[11] & LFN_ATTR_MASK) != LFN_ATTR:
            raise ValueError("record does not have the LFN attribute")
        if slot[13] != expected_checksum:
            raise ValueError("slot checksum does not match the short alias")
        ordered.append(slot_units(slot))

    units = [unit for part in reversed(ordered) for unit in part]
    try:
        end = units.index(0)
    except ValueError:
        end = len(units)

    if end > MAX_NAME_UNITS:
        raise ValueError("name exceeds the format's 255-unit limit")
    name_units = units[:end]
    tail = units[end + 1:] if end < len(units) else []
    if 0xFFFF in name_units or any(unit != 0xFFFF for unit in tail):
        raise ValueError("invalid terminator or padding")
    if not name_units:
        raise ValueError("empty long name")

    raw_name = struct.pack("<" + "H" * len(name_units), *name_units)
    return raw_name.decode("utf-16le", errors="strict")

The function validates slot-chain structure and strict UTF-16 decoding, but it cannot verify the supplied records’ on-disk locations and does not enforce every FAT long-name character-legality rule. The exact 0x0F attribute is selected through the standard long-name mask; a production parser still needs an explicit policy for unknown extension bits and invalid short-entry attribute combinations. Strict UTF-16 decoding is intentionally fail-closed. If an investigation needs to preserve ill-formed code units, store those raw units and surface a decoding error instead of substituting characters or normalizing the string.

For a useful test fixture, encode Quarterly Review 2026.txt into two logical 13-unit pieces, write them to disk as ordinal 2 with the final flag then ordinal 1, and follow them with a short entry whose 11-byte alias is REPORT~1TXT. The validator should return the long spelling. Flip one checksum byte, reorder the two slots, remove ordinal 1, or replace the short entry with a deleted/end marker; each altered fixture must be rejected. Keep the fixture in memory or in a disposable image, never on evidence media.

Record both interpretations, not just the display name

A repeatable forensic result records the image hash; volume and directory offsets; the raw 32 bytes of each slot; sequence values; decoded 13-unit fragments; alias bytes; computed and stored checksums; and each validation result. Preserve the original bytes even when decoding fails. A second parser can then independently reproduce the association decision without relying on screenshots or a translated name.

For operational diagnosis, compare three separate layers: the raw VFAT group, the short alias visible to classic DOS calls, and the name returned by the active LFN provider. A difference can come from a malformed on-disk chain, an 8.3-only application, a code-page conversion, a particular DOSLFN build, or a kernel/API path. Record the exact boot profile and kernel/driver versions. Do not use a repair utility as a parser, and do not assume that a checksum collision establishes that two names belong together.

Related:

Sources:

Comments