sonar

Scan files at memory bandwidth speed.
Log | Files | Refs

commit 4c32ba4db0569448115ac42eca612443640c6551
parent dc6fe10448fbabbd07277d00b9d318bd6c159389
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 16 Sep 2026 21:15:51 -0400

ntfs: build entry table from records

Mft holds one Entry per record slot plus a list of extra hard links. Records
may arrive in any order; extension records credit their attributes to the
base record, which removes any need to parse $ATTRIBUTE_LIST. Free records
are skipped because their contents are stale. Sizes come from the first
extent of each non-resident attribute only, so fragmented files are not
double counted, and every stream and index is summed into allocated because
they all occupy clusters. Names are interned as UTF-8 in a growing arena so
they never move. mft_path rebuilds paths and detects stale parents via
sequence numbers.

Diffstat:
Antfs/mft.odin | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 264 insertions(+), 0 deletions(-)

diff --git a/ntfs/mft.odin b/ntfs/mft.odin @@ -0,0 +1,264 @@ +package ntfs + +import "core:mem" +import "core:mem/virtual" +import "core:strings" +import "core:unicode/utf16" +import "core:unicode/utf8" + +// One row per MFT record. Indexed by record number in Mft.entries. +Entry :: struct { + parent: u32, // record number of the directory holding `name` + parent_sequence: u16, // sequence the parent had when this name was written; mismatch means orphan + sequence: u16, + link_count: u16, + namespace: Name_Space, // of `name` + flags: Record_Flags, // .In_Use and .Directory are the ones that matter + attributes: File_Attributes, // from $STANDARD_INFORMATION; .Reparse_Point marks junctions and cloud placeholders + name: string, // UTF-8; empty when the record has no $FILE_NAME (free records, extension records) + size: u64, // logical length of the unnamed $DATA stream + allocated: u64, // bytes on disk across every non-resident attribute: data, alternate streams, indexes +} + +// A file with several names. The first Win32 or POSIX name becomes Entry.name; the +// rest land here so they can be shown but never double-counted. +Hard_Link :: struct { + record: u32, + parent: u32, + name: string, +} + +Mft_Stats :: struct { + records: u64, // record slots in $MFT + records_read: u64, // slots that held a FILE record + records_bad: u64, // FILE records whose fixups failed + in_use: u64, + directories: u64, +} + +Mft :: struct { + entries: []Entry, + links: [dynamic]Hard_Link, + boot: Boot_Sector, + stats: Mft_Stats, + names: virtual.Arena, // backing store for every name string; never moves + allocator: mem.Allocator, +} + +mft_init :: proc(m: ^Mft, record_count: int, allocator := context.allocator) -> Error { + m.allocator = allocator + entries, err := make([]Entry, record_count, allocator) + if err != nil { + return .Out_Of_Memory + } + m.entries = entries + m.links = make([dynamic]Hard_Link, allocator) + if virtual.arena_init_growing(&m.names) != nil { + delete(m.entries, allocator) + delete(m.links) + return .Out_Of_Memory + } + m.stats.records = u64(record_count) + return .None +} + +mft_destroy :: proc(m: ^Mft) { + delete(m.entries, m.allocator) + delete(m.links) + virtual.arena_destroy(&m.names) + m^ = {} +} + +/* +Fold one fixed-up FILE record into the table. Records may arrive in any order. + +An extension record carries attributes that did not fit in its base record (a heavily +fragmented file, or one with many hard links). Its header names the base record, so +its attributes are credited to the base entry. This makes the $ATTRIBUTE_LIST +attribute unnecessary for our purposes: by visiting every record we see every +attribute anyway. + +Records not in use are skipped entirely. Their contents are stale and may point at +records that have since been reused. +*/ +mft_add_record :: proc(m: ^Mft, record_number: u32, rec: []byte) -> Error { + if len(rec) < size_of(Record_Header) { + return .Bad_Record + } + h := record_header(rec) + if h.magic != RECORD_MAGIC { + return .Bad_Record + } + if .In_Use not_in h.flags { + return .None + } + + owner := record_number + if h.base_record != 0 { + owner = u32(ref_record(h.base_record)) + if int(owner) >= len(m.entries) { + return .None + } + } + if int(owner) >= len(m.entries) { + return .Bad_Record + } + e := &m.entries[owner] + if owner == record_number { + e.sequence = h.sequence + e.link_count = h.link_count + e.flags = h.flags + m.stats.in_use += 1 + if .Directory in h.flags { + m.stats.directories += 1 + } + } + + it := attributes(rec) + for { + a, ok := next_attribute(&it) + if !ok { + break + } + #partial switch a.type { + case .Standard_Information: + if si, si_ok := standard_information(a); si_ok { + e.attributes = si.file_attributes + } + case .File_Name: + if fn, fn_ok := file_name(a); fn_ok { + add_name(m, owner, fn) + } + case .Data: + if a.non_resident { + // Only the first extent carries the sizes; later extents repeat them. + if a.lowest_vcn == 0 { + e.allocated += attr_disk_size(a) + if len(a.name) == 0 { + e.size = a.data_size + } + } + } else if len(a.name) == 0 { + e.size = u64(len(a.value)) + } + case: + // Directory indexes, bitmaps, reparse data, and EFS streams occupy clusters too. + if a.non_resident && a.lowest_vcn == 0 { + e.allocated += attr_disk_size(a) + } + } + } + return .None +} + +/* +Choose which $FILE_NAME becomes the entry's name. + +A file usually has one Win32 name and, when that name does not fit 8.3, a DOS alias +pointing at the same parent. The alias is not a separate link, so it is skipped +whenever a proper name exists. Any further Win32 or POSIX name is a real hard link. +*/ +@(private) +add_name :: proc(m: ^Mft, owner: u32, fn: File_Name) { + e := &m.entries[owner] + parent := u32(ref_record(fn.parent)) + parent_seq := ref_sequence(fn.parent) + + if e.name == "" { + e.name = intern_utf16(m, fn.name) + e.parent = parent + e.parent_sequence = parent_seq + e.namespace = fn.namespace + return + } + if fn.namespace == .Dos { + return + } + if e.namespace == .Dos { + // The alias arrived first; the real name replaces it. + e.name = intern_utf16(m, fn.name) + e.parent = parent + e.parent_sequence = parent_seq + e.namespace = fn.namespace + return + } + name := intern_utf16(m, fn.name) + if name == e.name && parent == e.parent { + return + } + append(&m.links, Hard_Link{record = owner, parent = parent, name = name}) +} + +// Copy a UTF-16LE name into the arena as UTF-8. Names are at most 255 code units. +@(private) +intern_utf16 :: proc(m: ^Mft, name: []u16) -> string { + runes: [256]rune + n := utf16.decode(runes[:], name) + total := 0 + for r in runes[:n] { + total += utf8.rune_size(r) + } + buf, err := make([]byte, total, virtual.arena_allocator(&m.names)) + if err != nil { + return "" + } + off := 0 + for r in runes[:n] { + enc, size := utf8.encode_rune(r) + copy(buf[off:], enc[:size]) + off += size + } + return string(buf) +} + +// True when the entry's parent reference still points at the record it was written for. +entry_parent_valid :: proc(m: ^Mft, e: Entry) -> bool { + if int(e.parent) >= len(m.entries) { + return false + } + p := m.entries[e.parent] + return .In_Use in p.flags && .Directory in p.flags && p.sequence == e.parent_sequence +} + +/* +Reconstruct the path of a record by following parent references up to the root. +The result starts with a backslash and omits the drive letter, e.g. +`\Windows\explorer.exe`. Records whose chain does not reach the root (files inside a +deleted directory, or a stale parent reference) yield a path rooted at the highest +reachable ancestor, prefixed with `<orphan>`. +*/ +mft_path :: proc(m: ^Mft, record: u32, allocator := context.allocator) -> string { + chain: [64]u32 + depth := 0 + r := record + orphan := false + for depth < len(chain) { + if int(r) >= len(m.entries) || r == RECORD_ROOT { + break + } + e := m.entries[r] + chain[depth] = r + depth += 1 + if !entry_parent_valid(m, e) || e.parent == r { + orphan = true + break + } + r = e.parent + } + if depth == len(chain) { + orphan = true + } + + sb := strings.builder_make(allocator) + if orphan { + strings.write_string(&sb, "<orphan>") + } + if depth == 0 { + strings.write_string(&sb, `\`) + } + for i := depth - 1; i >= 0; i -= 1 { + strings.write_string(&sb, `\`) + strings.write_string(&sb, m.entries[chain[i]].name) + } + return strings.to_string(sb) +}