sonar

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

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

ntfs: read $MFT from a Windows volume or image

Reading a live volume means opening \.\C: with administrator rights and
issuing sector-aligned reads. The reader parses the boot sector, fixes up
record 0, decodes $MFT's own run list, then streams the table in 16 MiB
chunks through those runs, folding each record into the table. The MFT is
addressed as a logical byte range mapped onto runs at read time, so a record
straddling two runs needs no special case. A stub for other platforms keeps
the pure parsing portable.

Diffstat:
Antfs/reader.odin | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Antfs/volume_other.odin | 22++++++++++++++++++++++
Antfs/volume_windows.odin | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 310 insertions(+), 0 deletions(-)

diff --git a/ntfs/reader.odin b/ntfs/reader.odin @@ -0,0 +1,198 @@ +package ntfs + +import "core:mem" + +// Alignment for I/O buffers. Raw volume reads want sector alignment; 4 KiB covers +// every sector size in use. +@(private) +IO_ALIGN :: 4096 + +DEFAULT_CHUNK_SIZE :: 16 * mem.Megabyte + +Read_Options :: struct { + chunk_size: int, // bytes per volume read; 0 selects DEFAULT_CHUNK_SIZE +} + +// Open `path` (drive letter or image file) and read its whole MFT into a table. +read_mft :: proc(path: string, opts := Read_Options{}, allocator := context.allocator) -> (m: Mft, err: Error) { + v, open_err := volume_open(path) + if open_err != nil { + return {}, open_err + } + defer volume_close(&v) + return read_mft_from_volume(&v, opts, allocator) +} + +/* +Read the MFT in three steps: + + 1. Boot sector, for cluster size, record size, and the MFT's first cluster. + 2. Record 0, which describes $MFT itself. Its $DATA run list says which clusters + hold the rest of the table, because the MFT is a file and can be fragmented. + 3. Stream the table through the run list in large chunks, fixing up and folding each + record into the entry table as it goes. + +The MFT is addressed as a contiguous logical byte range and `read_logical` maps each +chunk onto physical runs, so a record straddling two runs (possible when a cluster is +smaller than a record) is handled without special cases. +*/ +read_mft_from_volume :: proc(v: ^Volume, opts := Read_Options{}, allocator := context.allocator) -> (m: Mft, err: Error) { + // 1. Boot sector. + boot_buf, boot_alloc_err := mem.alloc_bytes(IO_ALIGN, IO_ALIGN, allocator) + if boot_alloc_err != nil { + return {}, .Out_Of_Memory + } + defer mem.free_bytes(boot_buf, allocator) + if read_err := volume_read_at(v, boot_buf, 0); read_err != nil { + return {}, read_err + } + boot, boot_err := parse_boot_sector(boot_buf) + if boot_err != nil { + return {}, boot_err + } + cluster := u64(boot.bytes_per_cluster) + record_size := int(boot.record_size) + + // 2. Record 0. + rec0_len := max(record_size, IO_ALIGN) + rec0_buf, rec0_alloc_err := mem.alloc_bytes(rec0_len, IO_ALIGN, allocator) + if rec0_alloc_err != nil { + return {}, .Out_Of_Memory + } + defer mem.free_bytes(rec0_buf, allocator) + if read_err := volume_read_at(v, rec0_buf, boot.mft_lcn * cluster); read_err != nil { + return {}, read_err + } + rec0 := rec0_buf[:record_size] + if fix_err := apply_fixups(rec0); fix_err != nil { + return {}, fix_err + } + if .In_Use not_in record_header(rec0).flags { + return {}, .Bad_Record + } + + runs: []Run + defer delete(runs, allocator) + mft_bytes: u64 + found := false + it := attributes(rec0) + for { + a, ok := next_attribute(&it) + if !ok { + break + } + if a.type != .Data || len(a.name) != 0 { + continue + } + if !a.non_resident { + return {}, .Mft_Data_Missing + } + if a.lowest_vcn != 0 { + continue + } + decoded, run_err := decode_runlist(a.runlist, 0, allocator) + if run_err != nil { + return {}, run_err + } + runs = decoded + mft_bytes = a.data_size + // If the run list in record 0 stops short of the data size, the rest of the + // mapping lives in extension records reached via $ATTRIBUTE_LIST. + if runlist_clusters(runs) * cluster < mft_bytes { + return {}, .Mft_Spans_Extension_Records + } + found = true + break + } + if !found { + return {}, .Mft_Data_Missing + } + + record_count := int(mft_bytes / u64(record_size)) + if init_err := mft_init(&m, record_count, allocator); init_err != nil { + return {}, init_err + } + m.boot = boot + // From here on, failures must release the table. + ok := false + defer if !ok { + mft_destroy(&m) + } + + // 3. Stream the table. + chunk := opts.chunk_size + if chunk <= 0 { + chunk = DEFAULT_CHUNK_SIZE + } + // A chunk must hold whole records and whole clusters so every read stays aligned. + unit := max(record_size, int(cluster)) + chunk = max(chunk / unit, 1) * unit + buf, buf_alloc_err := mem.alloc_bytes(chunk, IO_ALIGN, allocator) + if buf_alloc_err != nil { + return {}, .Out_Of_Memory + } + defer mem.free_bytes(buf, allocator) + + reader := Extent_Reader{v = v, runs = runs, cluster = cluster} + record_number: u32 = 0 + for offset: u64 = 0; offset < mft_bytes; offset += u64(chunk) { + n := int(min(u64(chunk), mft_bytes - offset)) + if read_err := read_logical(&reader, offset, buf[:n]); read_err != nil { + return {}, read_err + } + for start := 0; start + record_size <= n; start += record_size { + rec := buf[start:start + record_size] + defer record_number += 1 + // Slots never written are all zeros; skip them silently. + if rd32(rec, 0) != RECORD_MAGIC { + continue + } + m.stats.records_read += 1 + if apply_fixups(rec) != nil { + m.stats.records_bad += 1 + continue + } + if add_err := mft_add_record(&m, record_number, rec); add_err != nil { + m.stats.records_bad += 1 + } + } + } + ok = true + return m, .None +} + +@(private) +Extent_Reader :: struct { + v: ^Volume, + runs: []Run, + cluster: u64, + cursor: int, // runs are visited in ascending order, so remember where we were +} + +// Read a byte range of the attribute's logical address space by mapping it onto runs. +@(private) +read_logical :: proc(r: ^Extent_Reader, offset: u64, buf: []byte) -> Error { + done := 0 + for done < len(buf) { + pos := offset + u64(done) + vcn := pos / r.cluster + for r.cursor < len(r.runs) && r.runs[r.cursor].vcn + r.runs[r.cursor].length <= vcn { + r.cursor += 1 + } + if r.cursor >= len(r.runs) || r.runs[r.cursor].vcn > vcn { + return .Bad_Runlist + } + run := r.runs[r.cursor] + if run.sparse { + return .Bad_Runlist + } + in_run := pos - run.vcn * r.cluster + avail := run.length * r.cluster - in_run + n := int(min(u64(len(buf) - done), avail)) + if read_err := volume_read_at(r.v, buf[done:done + n], run.lcn * r.cluster + in_run); read_err != nil { + return read_err + } + done += n + } + return .None +} diff --git a/ntfs/volume_other.odin b/ntfs/volume_other.odin @@ -0,0 +1,22 @@ +#+build !windows +package ntfs + +// Raw volume access is only implemented for Windows. The parsing code above this +// layer is portable, so an image file reader for other platforms can slot in here. +Volume :: struct { + _: int, +} + +volume_open :: proc(path: string) -> (v: Volume, err: Error) { + _ = path + return {}, .Unsupported_Platform +} + +volume_close :: proc(v: ^Volume) { + _ = v +} + +volume_read_at :: proc(v: ^Volume, buf: []byte, offset: u64) -> Error { + _, _, _ = v, buf, offset + return .Unsupported_Platform +} diff --git a/ntfs/volume_windows.odin b/ntfs/volume_windows.odin @@ -0,0 +1,90 @@ +#+build windows +package ntfs + +import "core:strings" +import win "core:sys/windows" + +// Read-only handle to a raw volume (`\\.\C:`) or to an image file containing one. +Volume :: struct { + handle: win.HANDLE, +} + +@(private) +ERROR_ACCESS_DENIED :: 5 + +/* +Open a volume by drive letter (`C`, `C:`, `C:\`) or an NTFS image by file path. + +Volume handles bypass the file system, which is why they need administrator rights +and why every read must be a whole number of sectors at a sector-aligned offset. +FILE_SHARE_WRITE is required: the volume is mounted and in use, and opening it without +sharing writes would fail. +*/ +volume_open :: proc(path: string) -> (v: Volume, err: Error) { + name := path + if is_drive_spec(path) { + name = strings.concatenate({`\\.\`, path[:1], ":"}, context.temp_allocator) + } + wname := win.utf8_to_wstring(name, context.temp_allocator) + h := win.CreateFileW( + wname, + win.GENERIC_READ, + win.FILE_SHARE_READ | win.FILE_SHARE_WRITE | win.FILE_SHARE_DELETE, + nil, + win.OPEN_EXISTING, + win.FILE_FLAG_SEQUENTIAL_SCAN, + nil, + ) + if h == win.INVALID_HANDLE_VALUE { + if win.GetLastError() == ERROR_ACCESS_DENIED { + return {}, .Access_Denied + } + return {}, .Open_Failed + } + return Volume{handle = h}, .None +} + +volume_close :: proc(v: ^Volume) { + if v.handle != nil && v.handle != win.INVALID_HANDLE_VALUE { + win.CloseHandle(v.handle) + } + v.handle = nil +} + +// Fill `buf` from `offset`. Both must be sector-aligned for a raw volume. +volume_read_at :: proc(v: ^Volume, buf: []byte, offset: u64) -> Error { + done := 0 + for done < len(buf) { + ov: win.OVERLAPPED + ov.OffsetFull = offset + u64(done) + chunk := min(len(buf) - done, 1 << 30) + n: win.DWORD + if !win.ReadFile(v.handle, raw_data(buf[done:]), win.DWORD(chunk), &n, &ov) { + return .Read_Failed + } + if n == 0 { + return .Short_Read + } + done += int(n) + } + return .None +} + +@(private) +is_drive_spec :: proc(s: string) -> bool { + if len(s) == 0 || len(s) > 3 { + return false + } + c := s[0] + is_letter := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + if !is_letter { + return false + } + if len(s) >= 2 && s[1] != ':' { + return false + } + if len(s) == 3 && s[2] != '\\' && s[2] != '/' { + return false + } + return true +}