Pure Go parsing for FAT12, FAT16, and FAT32 volumes and disk images.
A small core package with Open, Volume, File, DirEntry, focused binary
helpers, typed errors, and example programs for listing, traversal, and
extraction.
Implemented now:
- FAT12, FAT16, and FAT32 boot sector parsing and validation
- Volume geometry derivation from the BPB
- FAT table cluster-chain resolution
- Root directory access for FAT12/16 and FAT32
- Short-name and long-file-name directory parsing
- Path-based file and directory opening
- File reads via
Read,ReadAt, andReadAll - Directory enumeration via
ReadDir - Per-file fragment / data-run export: absolute image byte ranges via
FragmentOffsets, with provenance flags for degraded chains, andRange.FileOffsetgiving each run's position in the file's own byte space - Whole-disk coordinates for a partition via
OpenOptions.BaseOffset, so every reported offset can be compared against externally supplied byte ranges with no adjustment by the caller - Recursive traversal via
Volume.Walk, with deleted records and orphan runs opt-in - Fragment-aware streaming reads (
Reader,ReaderAt,SectionReader) that do not buffer the whole file - Absolute directory-entry offsets (
EntryAbsoluteOffset), correct even for fragmented directories - File slack ranges via
SlackRange - Deleted-entry recovery metadata (
Deleted,Recovered,ClusterAllocated) - Optional long-name reconstruction for deleted entries
(
OpenOptions.RecoverDeletedLongNames) - Orphan recovery (
ScanOrphans): finds children of deleted directories that no path leads to - FAT32 FSInfo parsing with free-cluster and next-free hints
- Optional forced FAT type parsing via
OpenWithFATType - The volume's real label, read from the root directory's volume-ID record rather than the boot sector's stale copy, with both readings and their provenance exposed
- JSON forensic reports carrying
schema_version,library_versionand per-row extent provenance - Format introspection via
Volume.Capabilities(), so a caller handling several filesystems can ask what FAT records instead of special-casing on its name - Unit tests over synthetic images with explicitly chosen cluster layouts, an adversarial corpus, and fuzz targets asserting panic-freedom
- Opt-in validation against real disk images and a purpose-built 14-volume synthetic corpus, cross-checked against an independent implementation
- exFAT support is provided by the companion library: https://github.com/aoiflux/libxfat
Current limitations:
- Read-only library
- No write support
go get github.com/aoiflux/libfat- Requires Go 1.25+
package main
import (
"fmt"
"log"
"os"
"github.com/aoiflux/libfat"
)
func main() {
img, err := os.Open("disk.img")
if err != nil {
log.Fatal(err)
}
defer img.Close()
vol, err := libfat.Open(img)
if err != nil {
log.Fatal(err)
}
defer vol.Close()
root, err := vol.GetRootDirectory()
if err != nil {
log.Fatal(err)
}
entries, err := root.ReadDir()
if err != nil {
log.Fatal(err)
}
for _, entry := range entries {
kind := "FILE"
if entry.IsDirectory {
kind = "DIR "
}
fmt.Printf("[%s] %s (%d bytes)\n", kind, entry.Name, entry.Size)
}
}Optional forced FAT type (TSK-style claimed-type checking):
vol, err := libfat.OpenWithFATType(img, libfat.FAT16)
if err != nil {
log.Fatal(err)
}
defer vol.Close()Walk visits every entry depth first, in the order records appear on disk, so
callers do not hand-roll recursion over ReadDir:
ctx := context.Background()
err := v.Walk(ctx, func(path string, parentFirstCluster uint32, e libfat.DirEntry) error {
fmt.Println(path, e.Size)
return nil
})The root is not reported: it has no directory record anywhere on the volume, and
synthesising one would fabricate a structure that does not exist. The zero
WalkOptions covers the live, reachable tree; deleted records and orphan runs
are opt-in, which is deliberately unlike ReadDir.
err := v.WalkWithOptions(ctx, libfat.WalkOptions{
IncludeDeleted: true,
DescendDeletedDirectories: true,
IncludeOrphans: true,
}, fn)EntryAbsoluteOffset says where a record is now; FileID says which record it
is. A defragmentation pass rewrites the first for every entry in a directory
while changing no file — the slot index, being logical, does not move.
id, ok := v.FileID(entry) // {ParentFirstCluster, EntrySlotIndex}It is an address, not an identity. FAT has no inode and no reuse or generation
counter, so a slot reused after a deletion carries its previous occupant's
FileID exactly, and a rename that changes the number of long-name slots moves
the entry to a different one. Rename detection on FAT is inference, never
proof — corroborate with CreatedAt, FirstCluster and Size. See the File
identity section of the package documentation for the full account.
err := v.WriteReportDeep("evidence.img", os.Stdout)Every row carries a layout object holding the provenance flags — chain_walked,
assumed, truncated, chain_broken, loop_detected,
first_cluster_reallocated — none of them omitted when false, so a hypothesised
extent is never indistinguishable from a verified one. ReportDeep searches more
places but never relaxes the evidence: it does not set AssumeContiguous.
Each document names its own shape. schema_version increments only when a key
is removed, renamed, or changes meaning — never on an addition — so a
consumer that ignores unknown keys can pin to it rather than to a library
release. library_version records the build that wrote the report, and
generated the time it was written, which is the one key to exclude when
hashing two reports of the same volume for comparison.
For forensic work the central operation is mapping a file to the byte ranges it occupies in the image, without extracting it:
ranges, err := vol.FragmentOffsets(entry)
if err != nil && !errors.Is(err, libfat.ErrTruncatedChain) {
log.Fatal(err)
}
for _, r := range ranges {
fmt.Printf("%d bytes at image offset %d\n", r.Length, r.StartByte)
}Consecutive clusters are coalesced, so one Range means the file is contiguous
and more than one means it is fragmented. Ranges sum to the entry's size; the
unused tail of the final cluster is reported separately by SlackRange.
Ranges come back in file order and gap-free, and each carries FileOffset:
where that run begins inside the file itself. Mapping a changed image range back
to a position in the file therefore needs no accumulation by the caller. FAT has
no sparse allocation, so no run is a hole.
Offsets are absolute within the io.ReaderAt passed to Open, and include
OpenOptions.BaseOffset. For a partition inside a whole-disk image, either open
the whole image and set BaseOffset to the partition start, which makes every
reported offset a whole-disk offset:
v, err := libfat.OpenWithOptions(image, libfat.OpenOptions{
BaseOffset: partitionOffset,
})or layer an io.SectionReader at the partition offset and leave BaseOffset at
zero, which gives partition-relative offsets. Do one or the other: adding the
partition base to an offset that already includes it counts it twice.
Deletion frees a file's FAT entries, so the chain describing its layout is gone and only the first cluster survives in the directory entry. If that cluster has since been reallocated, walking the FAT from it would follow the new owner's chain. This library therefore never walks the FAT for a deleted entry:
result, err := vol.FragmentOffsetsWithOptions(entry, libfat.FragmentOptions{
AssumeContiguous: true, // opt in to contiguity-based reconstruction
})
// result.Assumed -> ranges are a hypothesis, not a fact
// result.FirstClusterReallocated -> content was most likely overwrittenWithout AssumeContiguous only the recorded first cluster is reported, with
Truncated set.
Deleting a directory frees its chain and marks its entry in the parent, but the
directory's own clusters keep intact entries for its children. Those children
are reachable from no path. ScanOrphans sweeps the data area for them:
result, err := vol.ScanOrphans(libfat.OrphanScanOptions{})
for _, dir := range result.Directories {
for _, entry := range dir.Entries {
fmt.Println(entry.Path, entry.Size) // rooted at libfat.OrphanPath
}
}The zero value is precision-first: only free clusters are examined, and only
those carrying self-consistent . and .. records are accepted.
AllowMissingDotEntries trades precision for recall. Original paths are not
recoverable — the chain that named these entries is what was lost — so they are
reported under /$OrphanFiles with DirEntry.Orphaned set.
A scan reads every candidate cluster, so it is far more expensive than ordinary
traversal; bound it with MaxClusters on large images.
Volume-level:
Open(reader io.ReaderAt) (*Volume, error)OpenWithFATType(reader io.ReaderAt, fatType string) (*Volume, error)OpenWithOptions(reader io.ReaderAt, options OpenOptions) (*Volume, error)(*Volume).GetRootDirectory() (*File, error)(*Volume).OpenPath(path string) (*File, error)(*Volume).OpenEntry(entry DirEntry) (*File, error)— the route to deleted and orphaned entries, which no path resolves to(*Volume).GetBootSector() *BootSector(*Volume).BaseOffset() int64— where the volume begins in the image, which every reported offset includes(*Volume).Capabilities() Capabilities— what FAT records, as distinct from what this volume happens to record(*Volume).VolumeLabel() string— the root directory's volume-ID record, falling back to the boot sector only when there is none, withBootSectorVolumeLabel()andVolumeLabelSource()exposing both readings
Walking the tree:
(*Volume).Walk(ctx, func(path string, parentFirstCluster uint32, e DirEntry) error) error(*Volume).WalkWithOptions(ctx, opts WalkOptions, fn) error— deleted records and unreachable directory data are opt-in, so one pass serves both change detection and recovery(*Volume).FileID(entry DirEntry) (FileID, bool)— the composite{ParentFirstCluster, EntrySlotIndex}identity, andFixedRootClusterfor the FAT12/16 root region
Reports:
(*Volume).Report(name string) (*FATReport, error),ReportDeep,ReportWithOptions,ReportWithOptionsContext(*Volume).WriteReport(name string, w io.Writer) error,WriteReportDeep,WriteReportWithOptions,WriteReportWithOptionsContext(*FATReport).Summary(),FilterFiles,FilesByType,DeletedFiles,OrphanedFiles,FragmentedFiles,AssumedFilesFATReportcarriesSchemaVersion(ReportSchemaVersion),LibraryVersionandGenerated; rows carryPathandNamealongsideFilename
Fragments and offsets:
(*Volume).FragmentOffsets(entry DirEntry) ([]Range, error)(*Volume).FragmentOffsetsWithOptions(entry DirEntry, opts FragmentOptions) (*FragmentResult, error)(*Volume).ClusterChainFragments(startCluster uint32, size uint64) ([]Range, error)(*Volume).RootDirectoryFragments() ([]Range, error)(*Volume).ClusterChain(startCluster uint32) ([]uint32, error)(*Volume).SlackRange(entry DirEntry) (Range, bool, error)(*Volume).IsFragmented(entry DirEntry) (bool, error)(*Volume).ScanOrphans(opts OrphanScanOptions) (*OrphanScanResult, error)(*Volume).ScanOrphansContext(ctx, opts OrphanScanOptions) (*OrphanScanResult, error)IsFragmented(ranges []Range) bool,TotalLength(ranges []Range) int64,Coalesce(ranges []Range) []Range
File-level:
(*File).Read(p []byte) (int, error)(*File).ReadAt(p []byte, offset int64) (int, error)(*File).ReadAll() ([]byte, error)(*File).ReadDir() ([]DirEntry, error)(*File).Fragments() ([]Range, error)(*File).IsFragmented() (bool, error)(*File).Reader() (io.ReadSeeker, error),(*File).ReaderAt() (io.ReaderAt, error)(*File).SectionReader() (*io.SectionReader, error)(contiguous files only)(*File).SetFragmentOptions(opts FragmentOptions)
examples/basic: open a volume/image (optionally force FAT12/FAT16/FAT32), print volume metadata, list root directoryexamples/traverse: walk the whole tree withVolume.Walk, printing each entry'sFileID;-deletedand-orphanswiden it to recovery materialexamples/extract: extract a file from a FAT volume to a local output pathexamples/fragments: print the absolute image byte ranges of every file, including deleted entries, slack, and orphans recovered from deleted directoriesexamples/report: write a JSON forensic report to stdout;-deepadds deleted records and unreachable directory data
Run one example:
cd examples/basic
go run . <fat_volume_or_image> [FAT12|FAT16|FAT32]- libxfat (exFAT parser): https://github.com/aoiflux/libxfat
go test ./...
go test -race ./...
go vet ./...
# Fuzz targets assert panic-freedom and that every returned range lies
# inside the volume.
go test -run XXX -fuzz '^FuzzFragmentOffsets$' -fuzztime 60s .The suite above builds its own images, which keeps it fast and lets it construct
damage on purpose, but cannot catch an assumption this library and its fixture
builder share. integration_test.go runs the same checks against volumes
written by mkfs.fat and by Windows, including a FAT32 partition inside a GPT
disk. They skip unless told where the images are:
LIBFAT_TEST_IMAGES=/path/to/images go test -run TestReal -v .integration_oracle_test.go goes further and compares libfat's reading against
one made by 7-Zip, whose FAT handler shares no code with this library, which is
the only way to catch a misreading that libfat makes consistently. Generate the
oracle once per image:
python3 testdata/make_fat_oracle.py /path/to/images/disk.ddrealVolumes in integration_test.go lists the images expected, along with
geometry decoded outside this library. A volume that is present but does not
match its entry is skipped rather than failed, since that is a fact about the
image rather than about libfat.
Real images are the better evidence, but the three on hand cover one sector size,
two of the three FAT types, no deletions, no orphans and no fragmented regular
file. testdata/gen_corpus.sh builds fourteen volumes that cover the rest, using
dosfstools and mtools — neither of which needs root, and neither of which
shares any code with libfat:
wsl.exe -- sh testdata/gen_corpus.sh /mnt/e/dataset/fat_synthcorpus_test.go reads them from $LIBFAT_TEST_IMAGES/fat_synth, or from
LIBFAT_CORPUS. Each volume's manifest entry records what only that volume
covers, and the generated README.md beside the images explains each one and
what the corpus still does not reach.
Version in fat.go is not derived from the git tag, and since it is reported
as library_version in every JSON report, a stale value silently misattributes
evidence. It went out of step once already; the checklist exists so it does not
again.
- Set
Versioninfat.goto the version being released. - Give the
## [Unreleased]heading inCHANGELOG.mdthe version and date, and open a fresh## [Unreleased]above it. - Bump
ReportSchemaVersiononly if a report key was removed, renamed, or changed meaning. A key being added, or a value becoming more correct, does not count — consumers pin to the schema to know how to parse, not to know what changed. go build ./... && go vet ./... && gofmt -l ., thengo test ./...andgo test -race ./....- Run the opt-in suites against the dataset, which the default
go testskips:LIBFAT_TEST_IMAGES=/path/to/images go test -run 'TestReal|TestCorpus' -timeout 40m . - Confirm
go.modstill has norequireblock. All six sibling filesystem libraries are zero-dependency and stay that way. - Tag.