sonar

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

main.odin (8477B)


      1 /*
      2 sonar: find out what is using the disk.
      3 
      4 Usage:
      5 	sonar [drive-or-image] [flags]     -h lists the flags
      6 
      7 Reads the volume's MFT directly and prints the largest files and directories. Reading
      8 a live volume needs an elevated prompt.
      9 */
     10 package main
     11 
     12 import "core:debug/trace"
     13 import "core:flags"
     14 import "core:fmt"
     15 import "core:os"
     16 import "core:time"
     17 
     18 @(require) import "jm:debug"
     19 import "ntfs"
     20 import "scan"
     21 import "walk"
     22 
     23 // Largest N files/folders to print.
     24 TOP_N :: 20
     25 
     26 PROGRAM :: "sonar"
     27 
     28 /*
     29 What the command line can set.
     30 
     31 Read in UNIX style, so `--flag`, `--flag=value` and `--flag value` all work, and `-h`
     32 prints this. A count left at zero means the reader chooses its own, rather than
     33 having a second set of defaults to keep in step here.
     34 */
     35 Options :: struct {
     36 	target:       string `args:"pos=0" usage:"Drive, device, image or directory to scan. Defaults to your home directory."`,
     37 	live:         bool `usage:"Draw the answer as it is found rather than once at the end."`,
     38 	buffered:     bool `usage:"Read the volume through the system cache rather than around it."`,
     39 	no_skip:      bool `usage:"Read runs of dead MFT records rather than jumping over them."`,
     40 	min_skip:     int `usage:"Shortest run of dead records worth jumping over, in bytes."`,
     41 	workers:      int `usage:"MFT readers to run at once."`,
     42 	walk_workers: int `usage:"Directory readers to run at once."`,
     43 	chunk:        int `usage:"Bytes per volume read."`,
     44 }
     45 
     46 main :: proc() {
     47 	os.exit(run())
     48 }
     49 
     50 run :: proc() -> int {
     51 	when ODIN_DEBUG {
     52 		da: debug.Allocator
     53 		debug.init(&da, context.allocator)
     54 		defer debug.destroy(&da)
     55 		defer debug.report(&da)
     56 		context.allocator = debug.allocator(&da)
     57 	}
     58 
     59 	defer free_all(context.temp_allocator)
     60 
     61 	context.assertion_failure_proc = trace.assertion_failure_proc
     62 
     63 	opt: Options
     64 	if err := flags.parse(&opt, os.args[1:], .Unix); err != nil {
     65 		// print_errors writes usage for a help request and the reason for anything
     66 		// else, so the only decision left here is what to exit with.
     67 		flags.print_errors(Options, err, PROGRAM, .Unix)
     68 		_, asked_for_help := err.(flags.Help_Request)
     69 		return 0 if asked_for_help else 1
     70 	}
     71 
     72 	target_str := opt.target
     73 	if target_str == "" {
     74 		target_str = os.user_home_dir(context.temp_allocator) or_else "/"
     75 	}
     76 	opts := ntfs.Read_Options {
     77 		chunk_size = opt.chunk,
     78 		io_mode    = .Buffered if opt.buffered else .Unbuffered,
     79 		// Negative reads every record; zero leaves the reader its own default.
     80 		min_skip   = -1 if opt.no_skip else opt.min_skip,
     81 		workers    = opt.workers,
     82 	}
     83 	wcfg := walk.Config {
     84 		workers = opt.walk_workers,
     85 	}
     86 
     87 	target, engine, resolve_err := scan.resolve(target_str)
     88 	if resolve_err != nil {
     89 		fmt.eprintfln("error: cannot scan %s: %v", target_str, resolve_err)
     90 		return 1
     91 	}
     92 	defer scan.target_destroy(&target)
     93 
     94 	fmt.printfln(
     95 		"sonar: %s on %s (%v, %v engine, %v io)",
     96 		scan.location(target, context.temp_allocator),
     97 		target.volume,
     98 		target.fs,
     99 		engine.engine,
    100 		opts.io_mode,
    101 	)
    102 
    103 	if engine.permission_would_help {
    104 		when ODIN_OS == .Windows {
    105 			fmt.eprintln("note: this volume reads far faster from an administrator prompt")
    106 		} else {
    107 			fmt.eprintfln(
    108 				"note: this volume reads far faster with permission to open %s directly",
    109 				target.volume,
    110 			)
    111 		}
    112 	}
    113 
    114 	if engine.engine == .None {
    115 		fmt.eprintfln("error: nothing here can be scanned")
    116 		return 1
    117 	}
    118 
    119 	t: scan.Tree
    120 	if err := scan.tree_init(&t, 8); err != nil {
    121 		fmt.eprintfln("error: %v", err)
    122 		return 1
    123 	}
    124 	defer scan.tree_destroy(&t)
    125 
    126 	// The table outlives the tree, whose names are borrowed from it.
    127 	f := Fill {
    128 		target = target,
    129 		engine = engine.engine,
    130 		opts   = opts,
    131 		wcfg   = wcfg,
    132 		tree   = &t,
    133 	}
    134 	defer fill_destroy(&f)
    135 
    136 	if opt.live {
    137 		return run_live(&f)
    138 	}
    139 
    140 	start := time.tick_now()
    141 	fill_run(&f)
    142 	if fill_report(&f) {
    143 		return 1
    144 	}
    145 	elapsed := time.tick_since(start)
    146 
    147 	if engine.engine == .Mft {
    148 		print_native(&f.table, elapsed)
    149 	}
    150 	fmt.println()
    151 	fmt.printfln(
    152 		"tree       %d slots, %d used, read in %.0f ms",
    153 		t.count,
    154 		t.nodes_done,
    155 		time.duration_milliseconds(elapsed),
    156 	)
    157 
    158 	// The same charging the live scan does, run once over a tree nobody is still
    159 	// filling. A node left uncharged here has an ancestor that never resolved, and
    160 	// another pass would not change that.
    161 	roll: Rollup
    162 	defer rollup_destroy(&roll)
    163 	rollup_advance(&roll, &t)
    164 
    165 	print_largest(&t, roll.totals[:], .files)
    166 	print_largest(&t, roll.totals[:], .directories)
    167 	return 0
    168 }
    169 
    170 Kind :: enum {
    171 	files,
    172 	directories,
    173 }
    174 
    175 // The directories that hold the most, largest first.
    176 rank :: proc(t: ^scan.Tree, totals: []u64, top: []Sized) -> int {
    177 	count := 0
    178 	// Bounded by the totals rather than the tree: a reader may hand out more slots
    179 	// while this runs, and those have not been charged yet.
    180 	for i in 0 ..< u32(len(totals)) {
    181 		f := scan.node_flags(scan.node(t, i))
    182 		if .Used not_in f || .Directory not_in f {
    183 			continue
    184 		}
    185 		if totals[i] == 0 {
    186 			continue
    187 		}
    188 		top_n(top, &count, Sized{bytes = totals[i], record = i})
    189 	}
    190 	return count
    191 }
    192 
    193 print_largest :: proc(t: ^scan.Tree, totals: []u64, kind: Kind) {
    194 	top: [TOP_N]Sized
    195 	count := 0
    196 	for i in 0 ..< u32(len(totals)) {
    197 		n := scan.node(t, i)
    198 		flags := scan.node_flags(n)
    199 		if .Used not_in flags {
    200 			continue
    201 		}
    202 		bytes: u64
    203 		switch kind {
    204 		case .files:
    205 			if .Directory in flags || .Extra_Name in flags {
    206 				continue
    207 			}
    208 			bytes = n.disk
    209 		case .directories:
    210 			if .Directory not_in flags {
    211 				continue
    212 			}
    213 			bytes = totals[i]
    214 		}
    215 		if bytes == 0 {
    216 			continue
    217 		}
    218 		top_n(top[:], &count, Sized{bytes = bytes, record = i})
    219 	}
    220 	fmt.println()
    221 	fmt.printfln("largest %v (%d)", kind, count)
    222 	for s in top[:count] {
    223 		fmt.printfln("  %10s  %s", human(s.bytes), scan.path(t, s.record, context.temp_allocator))
    224 	}
    225 }
    226 
    227 // What only the NTFS reader knows, kept because the tree cannot carry it.
    228 print_native :: proc(m: ^ntfs.Mft, elapsed: time.Duration) {
    229 
    230 	files: u64
    231 	allocated: u64
    232 	for e in m.entries {
    233 		if .In_Use not_in e.flags {
    234 			continue
    235 		}
    236 		allocated += e.allocated
    237 		if .Directory not_in e.flags {
    238 			files += 1
    239 		}
    240 	}
    241 	volume_bytes := m.boot.total_sectors * u64(m.boot.bytes_per_sector)
    242 	fmt.println()
    243 	fmt.printfln(
    244 		"volume     %s, %d B clusters, %d B records",
    245 		human(volume_bytes),
    246 		m.boot.bytes_per_cluster,
    247 		m.boot.record_size,
    248 	)
    249 	fmt.printfln(
    250 		"mft        %d slots, %d records, %d unreadable, read in %.0f ms",
    251 		m.stats.records,
    252 		m.stats.records_read,
    253 		m.stats.records_bad,
    254 		time.duration_milliseconds(elapsed),
    255 	)
    256 	fmt.printfln(
    257 		"in use     %d files, %d directories, %d extra hard links",
    258 		files,
    259 		m.stats.directories,
    260 		len(m.links),
    261 	)
    262 	fmt.printfln("attributed %s summed from every file run list", human(allocated))
    263 
    264 	/*
    265 	$Bitmap counts allocated clusters without consulting a single file, so comparing it
    266 	against the sum above checks the run list decoding. The two should agree closely;
    267 	a wide gap means clusters are allocated that no file claims, which is a parsing bug
    268 	rather than missing disk space.
    269 	*/
    270 	on_disk := m.stats.allocated_clusters * u64(m.boot.bytes_per_cluster)
    271 	if on_disk > 0 {
    272 		fmt.printfln("on disk    %s marked allocated in $Bitmap", human(on_disk))
    273 		if on_disk >= allocated {
    274 			gap := on_disk - allocated
    275 			fmt.printfln(
    276 				"unclaimed  %s (%.2f%%) allocated but charged to no file",
    277 				human(gap),
    278 				100 * f64(gap) / f64(on_disk),
    279 			)
    280 		} else {
    281 			fmt.printfln(
    282 				"overcount  %s more attributed than $Bitmap reports allocated",
    283 				human(allocated - on_disk),
    284 			)
    285 		}
    286 	}
    287 	if m.stats.resident_files > 0 {
    288 		fmt.printfln(
    289 			"resident   %d files hold %s inside their MFT records, charged to $MFT",
    290 			m.stats.resident_files,
    291 			human(m.stats.resident_bytes),
    292 		)
    293 	}
    294 }
    295 
    296 Sized :: struct {
    297 	bytes:  u64,
    298 	record: u32,
    299 }
    300 
    301 // Keep the N largest candidates in descending order without sorting the whole table.
    302 // Most candidates fail the first comparison, so the scan stays linear.
    303 @(private = "file")
    304 top_n :: proc(top: []Sized, count: ^int, cand: Sized) {
    305 	if count^ == len(top) && cand.bytes <= top[len(top) - 1].bytes {
    306 		return
    307 	}
    308 	i := count^ if count^ < len(top) else len(top) - 1
    309 	for i > 0 && top[i - 1].bytes < cand.bytes {
    310 		top[i] = top[i - 1]
    311 		i -= 1
    312 	}
    313 	top[i] = cand
    314 	if count^ < len(top) {
    315 		count^ += 1
    316 	}
    317 }
    318 
    319 human :: proc(n: u64) -> string {
    320 	units := [?]string{"B", "KiB", "MiB", "GiB", "TiB"}
    321 	v := f64(n)
    322 	i := 0
    323 	for v >= 1024 && i < len(units) - 1 {
    324 		v /= 1024
    325 		i += 1
    326 	}
    327 	if i == 0 {
    328 		return fmt.tprintf("%d B", n)
    329 	}
    330 	return fmt.tprintf("%.1f %s", v, units[i])
    331 }