sonar

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

commit e3bb97356ea248a9a15a6fcb657f652324c24491
parent 85353cbace6545d9c421898f6fc755e9f7c5c45e
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 11:32:50 -0400

scan: add the seam between readers and the tree

Readers for NTFS, for plain directory walking, and later for other filesystems
all answer the same question and must agree on how the answer is recorded.
This package is where that agreement lives.

It starts with the two things every reader needs before it runs. Resolving a
target asks the OS which volume a path sits on and what filesystem that volume
runs, rather than guessing from the shape of the string, so the failures that
are knowable up front are reported by the layer that understands them.
Choosing a reader is then a question about a struct.

Dispatch is a switch, not an interface: the set of readers is fixed when the
program is built.

Diffstat:
MMakefile | 3++-
Ascan/scan.odin | 21+++++++++++++++++++++
Ascan/scan_test.odin | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascan/target.odin | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascan/target_other.odin | 10++++++++++
Ascan/target_windows.odin | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 309 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile @@ -20,7 +20,7 @@ FLAGS := -vet -strict-style SAN ?= -sanitize:address EXE := $(if $(filter Windows_NT,$(OS)),.exe,) -SRC := $(wildcard *.odin) $(wildcard ntfs/*.odin) $(wildcard debug/*.odin) $(wildcard flow/*.odin) +SRC := $(wildcard *.odin) $(wildcard ntfs/*.odin) $(wildcard debug/*.odin) $(wildcard flow/*.odin) $(wildcard scan/*.odin) .PHONY: all debug release test check clean @@ -39,6 +39,7 @@ $(BUILD)/release/sonar$(EXE): $(SRC) | $(BUILD)/release test: | $(BUILD)/test $(ODIN) test ntfs -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/ntfs$(EXE) $(ODIN) test flow -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/flow$(EXE) + $(ODIN) test scan -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/scan$(EXE) check: $(ODIN) check . $(FLAGS) diff --git a/scan/scan.odin b/scan/scan.odin @@ -0,0 +1,21 @@ +/* +Package scan holds the seam between the readers that find files and the tree the UI +reads. + +A reader knows how to discover files on one kind of filesystem. It does not get to +decide how they are recorded, because everything above this line has to work the same +whichever reader ran. So the shared parts live here: resolving what the user asked +for, choosing a reader for it, and the tree they all write into. + +Dispatch is a switch at the one place a reader is chosen, not an interface. The set +of readers is known when the program is built, so nothing is gained by describing it +to the compiler at runtime. +*/ +package scan + +Error :: enum { + None, + Target_Not_Found, + Out_Of_Memory, + Unsupported_Platform, +} diff --git a/scan/scan_test.odin b/scan/scan_test.odin @@ -0,0 +1,62 @@ +package scan + +import "core:testing" + +@(test) +test_choose_prefers_the_mft_when_it_can_be_read :: proc(t: ^testing.T) { + ntfs := Target{fs = .Ntfs, volume = `\\.\C:`} + testing.expect_value(t, choose(ntfs, elevated = true).engine, Engine.Mft) + + // Without the privilege the reader is unavailable rather than merely slower, so + // the fallback runs and the caller is told relaunching would pay. + fallback := choose(ntfs, elevated = false) + testing.expect_value(t, fallback.engine, Engine.Walk) + testing.expect(t, fallback.elevation_would_help) +} + +@(test) +test_choose_walks_what_it_cannot_read_directly :: proc(t: ^testing.T) { + // Elevation buys nothing on these, so it must not be suggested. + for fs in ([]Filesystem{.Refs, .Exfat, .Fat32, .Network}) { + c := choose(Target{fs = fs, volume = `\\.\D:`}, elevated = true) + testing.expect_value(t, c.engine, Engine.Walk) + testing.expect(t, !c.elevation_would_help) + } +} + +@(test) +test_choose_gives_up_on_a_target_with_no_volume :: proc(t: ^testing.T) { + testing.expect_value(t, choose(Target{}, elevated = true).engine, Engine.None) +} + +@(test) +test_resolve_finds_the_volume_behind_a_path :: proc(t: ^testing.T) { + when ODIN_OS != .Windows { + return + } + // The system drive is the one target every Windows machine has, and reading its + // name needs no privilege. + root, err := resolve("C:") + testing.expect_value(t, err, Error.None) + defer target_destroy(&root) + testing.expect_value(t, root.mount, `C:\`) + testing.expect_value(t, root.volume, `\\.\C:`) + testing.expect_value(t, root.root, "") + + // A path below the mount point keeps the same volume and records the subtree. + sub, sub_err := resolve(`C:\Windows`) + testing.expect_value(t, sub_err, Error.None) + defer target_destroy(&sub) + testing.expect_value(t, sub.volume, root.volume) + testing.expect_value(t, sub.root, `\Windows`) + testing.expect_value(t, sub.fs, root.fs) +} + +@(test) +test_resolve_rejects_what_is_not_there :: proc(t: ^testing.T) { + when ODIN_OS != .Windows { + return + } + _, err := resolve(`Q:\no\such\path`) + testing.expect_value(t, err, Error.Target_Not_Found) +} diff --git a/scan/target.odin b/scan/target.odin @@ -0,0 +1,73 @@ +package scan + +import "core:mem" + +/* +What the user asked to scan, resolved once so no backend has to re-derive it. + +Deciding which volume a path lives on and what filesystem that volume runs is work +for the OS, not string matching, and every backend would otherwise repeat it. Doing +it here also puts the failures that are knowable up front, a path that does not +exist or a share with no volume behind it, in the one place that can explain them. +*/ +Target :: struct { + input: string, // exactly what was given + volume: string, // device path to open, e.g. \\.\C: + mount: string, // where that volume is mounted, e.g. C:\ + root: string, // subtree within the volume; "" means all of it + fs: Filesystem, + image: bool, // a file holding a volume rather than a mounted one + allocator: mem.Allocator, +} + +Filesystem :: enum { + Unknown, + Ntfs, + Refs, + Exfat, + Fat32, + Network, +} + +target_destroy :: proc(t: ^Target) { + delete(t.input, t.allocator) + delete(t.volume, t.allocator) + delete(t.mount, t.allocator) + delete(t.root, t.allocator) + t^ = {} +} + +// Which reader to use. Named here so choosing needs no knowledge of the backends +// themselves, which keeps this package free of a dependency on any of them. +Engine :: enum { + None, // nothing here can be scanned + Mft, // read the NTFS master file table whole + Walk, // enumerate directories +} + +Choice :: struct { + engine: Engine, + // The MFT reader is far faster but needs a raw volume handle. When this is set, + // Walk is what will run unless the caller elevates and asks again. + elevation_would_help: bool, +} + +/* +Pick a reader for a target. + +The MFT reader takes the whole volume at once, so a subtree costs no more than the +root does and is far cheaper than walking it. That makes a subtree target a reason to +filter the result, never a reason to reject the reader. +*/ +choose :: proc(t: Target, elevated: bool) -> Choice { + if t.fs == .Ntfs { + if elevated { + return {engine = .Mft} + } + return {engine = .Walk, elevation_would_help = true} + } + if t.fs == .Unknown && t.volume == "" { + return {engine = .None} + } + return {engine = .Walk} +} diff --git a/scan/target_other.odin b/scan/target_other.odin @@ -0,0 +1,10 @@ +#+build !windows +package scan + +// Target resolution needs the OS to say which volume a path lives on and what +// filesystem it runs, so it lands here per platform. Unix answers both from the +// mount table. +resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, err: Error) { + _, _ = input, allocator + return {}, .Unsupported_Platform +} diff --git a/scan/target_windows.odin b/scan/target_windows.odin @@ -0,0 +1,141 @@ +#+build windows +package scan + +import "core:strings" +import win "core:sys/windows" + +foreign import kernel32 "system:Kernel32.lib" + +@(default_calling_convention = "system") +foreign kernel32 { + // Not bound by core:sys/windows, so declared here. + GetVolumePathNameW :: proc(lpszFileName: win.LPCWSTR, lpszVolumePathName: win.LPWSTR, cchBufferLength: win.DWORD) -> win.BOOL --- + GetVolumeInformationW :: proc(lpRootPathName: win.LPCWSTR, lpVolumeNameBuffer: win.LPWSTR, nVolumeNameSize: win.DWORD, lpVolumeSerialNumber: ^win.DWORD, lpMaximumComponentLength: ^win.DWORD, lpFileSystemFlags: ^win.DWORD, lpFileSystemNameBuffer: win.LPWSTR, nFileSystemNameSize: win.DWORD) -> win.BOOL --- + GetCurrentProcess :: proc() -> win.HANDLE --- +} + +/* +Resolve what was given into a target backends can dispatch on. + +A bare drive letter is accepted for convenience, an existing path is asked about, and +anything else is taken to be an image file holding a volume. Failing here is better +than failing inside a backend, because this is the layer that knows why. +*/ +resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, err: Error) { + t.allocator = allocator + t.input = strings.clone(input, allocator) + + path := input + if is_drive_spec(input) { + path = strings.concatenate({input[:1], `:\`}, context.temp_allocator) + } + wpath := win.utf8_to_wstring(path, context.temp_allocator) + + attrs := win.GetFileAttributesW(wpath) + if attrs == win.INVALID_FILE_ATTRIBUTES { + target_destroy(&t) + return {}, .Target_Not_Found + } + + // A file that is not a directory is taken to be an image of a volume. Nothing + // here can say which filesystem is inside it, so a backend has to look. + if attrs & win.FILE_ATTRIBUTE_DIRECTORY == 0 { + t.image = true + t.volume = strings.clone(path, allocator) + return t, .None + } + + // The mount point tells us which volume the path sits on, which is the only way + // to be right about a directory mounted from another volume. + mount_buf: [win.MAX_PATH]u16 + if !GetVolumePathNameW(wpath, &mount_buf[0], len(mount_buf)) { + target_destroy(&t) + return {}, .Target_Not_Found + } + mount, mount_err := win.wstring_to_utf8(win.wstring(&mount_buf[0]), -1, allocator) + if mount_err != nil { + target_destroy(&t) + return {}, .Out_Of_Memory + } + t.mount = mount + + fs_buf: [win.MAX_PATH]u16 + if GetVolumeInformationW(win.wstring(&mount_buf[0]), nil, 0, nil, nil, nil, &fs_buf[0], len(fs_buf)) { + name, name_err := win.wstring_to_utf8(win.wstring(&fs_buf[0]), -1, context.temp_allocator) + if name_err == nil { + t.fs = filesystem_from_name(name) + } + } + + // A mount point of the form `C:\` opens as `\\.\C:`; anything else is a volume + // mounted into a directory, which needs its own device path and is left alone. + if len(t.mount) >= 2 && t.mount[1] == ':' { + t.volume = strings.concatenate({`\\.\`, t.mount[:1], ":"}, allocator) + } + if strings.has_prefix(t.mount, `\\`) { + t.fs = .Network + } + + // Whatever of the path lies below the mount point is the subtree to report on. + if len(path) > len(t.mount) { + t.root = strings.clone(path[len(t.mount) - 1:], allocator) + } + return t, .None +} + +@(private) +filesystem_from_name :: proc(name: string) -> Filesystem { + switch name { + case "NTFS": + return .Ntfs + case "ReFS": + return .Refs + case "exFAT": + return .Exfat + case "FAT32", "FAT": + return .Fat32 + } + return .Unknown +} + +// `C`, `C:` and `C:\` all mean the same volume. +@(private) +is_drive_spec :: proc(s: string) -> bool { + if len(s) == 0 || len(s) > 3 { + return false + } + c := s[0] + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { + return false + } + if len(s) >= 2 && s[1] != ':' { + return false + } + if len(s) == 3 && s[2] != '\\' && s[2] != '/' { + return false + } + return true +} + +/* +Whether this process can open a raw volume handle. + +Asked before choosing a reader so the choice can be explained: the MFT reader is much +faster but needs this, and without it the caller may want to relaunch rather than +settle for walking directories. +*/ +elevated :: proc() -> bool { + token: win.HANDLE + if !win.OpenProcessToken(GetCurrentProcess(), win.TOKEN_QUERY, &token) { + return false + } + defer win.CloseHandle(token) + + // TOKEN_ELEVATION is a single DWORD: non-zero when the token is elevated. + value: win.DWORD + size: win.DWORD + if !win.GetTokenInformation(token, .TokenElevation, &value, size_of(value), &size) { + return false + } + return value != 0 +}