commit 2f6bf9010e22a6ebf403a2e9e0d30ce9064326b9
parent d66668cf8754a802792ffc92319889d3f5148f0a
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 16 Sep 2026 21:18:31 -0400
main: print largest files and directories from the MFT
First end-to-end use of the ntfs package and the smoke test for a live
volume. Directory totals are computed by crediting each entry's allocation to
every ancestor, a single linear pass since average depth is under ten. Top-N
selection keeps a small sorted array instead of sorting millions of entries.
The debug build wires in the lifetime allocator one frame below main so its
report runs before os.exit; the import is @(require) so release builds, where
that block compiles away, do not reject it as unused.
Diffstat:
| A | main.odin | | | 200 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 200 insertions(+), 0 deletions(-)
diff --git a/main.odin b/main.odin
@@ -0,0 +1,200 @@
+/*
+sonar: find out what is using the disk.
+
+Usage:
+ sonar [drive-or-image] default C:
+
+Reads the volume's MFT directly and prints the largest files and directories. Reading
+a live volume needs an elevated prompt.
+*/
+package main
+
+import "core:debug/trace"
+import "core:fmt"
+import "core:os"
+import "core:time"
+
+// Only referenced inside `when ODIN_DEBUG`; @(require) keeps release builds from
+// rejecting the import as unused.
+@(require) import "lifetime"
+import "ntfs"
+
+TOP_N :: 20
+
+main :: proc() {
+ os.exit(debug_main())
+}
+
+// Deferred cleanup must run before os.exit, so the debug wiring lives one frame down.
+debug_main :: proc() -> int {
+ // Assertion failures print a call chain in every build; symbols need -debug.
+ context.assertion_failure_proc = trace.assertion_failure_proc
+ when ODIN_DEBUG {
+ la: lifetime.Allocator
+ lifetime.init(&la, context.allocator)
+ defer lifetime.destroy(&la)
+ defer lifetime.report(&la)
+ context.allocator = lifetime.allocator(&la)
+ }
+ return run()
+}
+
+run :: proc() -> int {
+ defer free_all(context.temp_allocator)
+
+ target := "C:"
+ if len(os.args) > 1 {
+ target = os.args[1]
+ }
+ if target == "-h" || target == "--help" || target == "/?" {
+ fmt.println("usage: sonar [drive-or-image] (default C:)")
+ return 0
+ }
+
+ fmt.printfln("sonar: reading MFT of %s", target)
+ start := time.tick_now()
+ m, err := ntfs.read_mft(target)
+ if err != nil {
+ #partial switch err {
+ case .Access_Denied:
+ fmt.eprintln("error: access denied. Reading a raw volume needs an administrator prompt.")
+ case .Not_Ntfs:
+ fmt.eprintln("error: not an NTFS volume")
+ case .Open_Failed:
+ fmt.eprintfln("error: could not open %s", target)
+ case:
+ fmt.eprintfln("error: %v", err)
+ }
+ return 1
+ }
+ defer ntfs.mft_destroy(&m)
+ elapsed := time.tick_since(start)
+
+ prefix := ""
+ if len(target) <= 3 {
+ prefix = fmt.tprintf("%c:", target[0])
+ }
+
+ print_summary(&m, elapsed)
+ print_largest_files(&m, prefix)
+ print_largest_directories(&m, prefix)
+ return 0
+}
+
+print_summary :: proc(m: ^ntfs.Mft, elapsed: time.Duration) {
+ files: u64
+ allocated: u64
+ for e in m.entries {
+ if .In_Use not_in e.flags {
+ continue
+ }
+ allocated += e.allocated
+ if .Directory not_in e.flags {
+ files += 1
+ }
+ }
+ volume_bytes := m.boot.total_sectors * u64(m.boot.bytes_per_sector)
+ fmt.println()
+ fmt.printfln("volume %s, %d B clusters, %d B records", human(volume_bytes), m.boot.bytes_per_cluster, m.boot.record_size)
+ fmt.printfln("mft %d slots, %d records, %d unreadable, read in %.0f ms", m.stats.records, m.stats.records_read, m.stats.records_bad, time.duration_milliseconds(elapsed))
+ fmt.printfln("in use %d files, %d directories, %d extra hard links", files, m.stats.directories, len(m.links))
+ fmt.printfln("allocated %s across all data streams and indexes", human(allocated))
+}
+
+Sized :: struct {
+ bytes: u64,
+ record: u32,
+}
+
+// Keep the N largest candidates in descending order without sorting the whole table.
+// Most candidates fail the first comparison, so the scan stays linear.
+@(private = "file")
+top_n :: proc(top: []Sized, count: ^int, cand: Sized) {
+ if count^ == len(top) && cand.bytes <= top[len(top) - 1].bytes {
+ return
+ }
+ i := count^ if count^ < len(top) else len(top) - 1
+ for i > 0 && top[i - 1].bytes < cand.bytes {
+ top[i] = top[i - 1]
+ i -= 1
+ }
+ top[i] = cand
+ if count^ < len(top) {
+ count^ += 1
+ }
+}
+
+print_largest_files :: proc(m: ^ntfs.Mft, prefix: string) {
+ top: [TOP_N]Sized
+ count := 0
+ for e, i in m.entries {
+ if .In_Use not_in e.flags || .Directory in e.flags || e.allocated == 0 {
+ continue
+ }
+ top_n(top[:], &count, Sized{bytes = e.allocated, record = u32(i)})
+ }
+ fmt.println()
+ fmt.printfln("largest files (%d)", count)
+ for s in top[:count] {
+ fmt.printfln(" %10s %s%s", human(s.bytes), prefix, ntfs.mft_path(m, s.record, context.temp_allocator))
+ }
+}
+
+/*
+Directory sizes are the point of the tool. Each entry's allocation is credited to
+every ancestor by walking parent references; average depth is under ten, so this is a
+single cheap pass even on millions of records. Entries whose parent chain is broken
+(deleted directory, stale reference) stop at the last valid ancestor.
+*/
+print_largest_directories :: proc(m: ^ntfs.Mft, prefix: string) {
+ totals := make([]u64, len(m.entries))
+ defer delete(totals)
+
+ for e, i in m.entries {
+ if .In_Use not_in e.flags || e.name == "" {
+ continue
+ }
+ if .Directory in e.flags {
+ totals[i] += e.allocated
+ }
+ cur := e
+ for depth := 0; depth < 64; depth += 1 {
+ if !ntfs.entry_parent_valid(m, cur) {
+ break
+ }
+ totals[cur.parent] += e.allocated
+ if cur.parent == ntfs.RECORD_ROOT {
+ break
+ }
+ cur = m.entries[cur.parent]
+ }
+ }
+
+ top: [TOP_N]Sized
+ count := 0
+ for e, i in m.entries {
+ if .In_Use not_in e.flags || .Directory not_in e.flags || totals[i] == 0 {
+ continue
+ }
+ top_n(top[:], &count, Sized{bytes = totals[i], record = u32(i)})
+ }
+ fmt.println()
+ fmt.printfln("largest directories (%d)", count)
+ for s in top[:count] {
+ fmt.printfln(" %10s %s%s", human(s.bytes), prefix, ntfs.mft_path(m, s.record, context.temp_allocator))
+ }
+}
+
+human :: proc(n: u64) -> string {
+ units := [?]string{"B", "KiB", "MiB", "GiB", "TiB"}
+ v := f64(n)
+ i := 0
+ for v >= 1024 && i < len(units) - 1 {
+ v /= 1024
+ i += 1
+ }
+ if i == 0 {
+ return fmt.tprintf("%d B", n)
+ }
+ return fmt.tprintf("%.1f %s", v, units[i])
+}