commit 564dc3c0f4b56980178e084cb56e54f8b85adbe9
parent 956dcddfe266dbecb479a07c5901848031460c80
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 14:47:24 -0300
scan: choose a reader from whether the volume opens
`elevated` asked about the process, which is the wrong question and only Windows
could answer it. Off Windows it returned a constant true, justified by a comment
reasoning about what `resolve` had already done. Being in the group that owns a
device grants the read without root, so the honest question is whether this
volume opens, which also covers one that is missing or locked.
`resolve` finds that out and now returns the reader with the target, since it is
the only thing that knew. Choosing stays a separate procedure so the decision
table can still be tested without a disk, but nothing outside needs to call it.
Diffstat:
5 files changed, 125 insertions(+), 69 deletions(-)
diff --git a/main.odin b/main.odin
@@ -85,15 +85,14 @@ run :: proc() -> int {
}
// Resolve once, so the reader is chosen from what the OS says rather than from
- // the shape of the string.
- resolved, resolve_err := scan.resolve(target)
+ // the shape of the string, and comes back with it.
+ resolved, choice, resolve_err := scan.resolve(target)
if resolve_err != nil {
fmt.eprintfln("error: cannot scan %s: %v", target, resolve_err)
return 1
}
defer scan.target_destroy(&resolved)
- choice := scan.choose(resolved, scan.elevated())
fmt.printfln(
"sonar: %s on %s (%v, %v engine, %v io)",
scan.location(resolved, context.temp_allocator),
@@ -102,8 +101,15 @@ run :: proc() -> int {
choice.engine,
opts.io_mode,
)
- if choice.elevation_would_help {
- fmt.eprintln("note: this volume reads far faster from an administrator prompt")
+ if choice.permission_would_help {
+ when ODIN_OS == .Windows {
+ fmt.eprintln("note: this volume reads far faster from an administrator prompt")
+ } else {
+ fmt.eprintfln(
+ "note: this volume reads far faster with permission to open %s directly",
+ resolved.volume,
+ )
+ }
}
if choice.engine == .None {
fmt.eprintfln("error: nothing here can be scanned")
diff --git a/scan/scan_test.odin b/scan/scan_test.odin
@@ -8,13 +8,13 @@ test_choose_prefers_the_mft_when_it_can_be_read :: proc(t: ^testing.T) {
fs = .Ntfs,
volume = `\\.\C:`,
}
- testing.expect_value(t, choose(ntfs, elevated = true).engine, Engine.Mft)
+ testing.expect_value(t, choose(ntfs, readable = 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)
+ // A volume that would not open leaves the reader unavailable rather than merely
+ // slower, so the fallback runs and the caller is told what would pay.
+ fallback := choose(ntfs, readable = false)
testing.expect_value(t, fallback.engine, Engine.Walk)
- testing.expect(t, fallback.elevation_would_help)
+ testing.expect(t, fallback.permission_would_help)
}
@(test)
@@ -23,9 +23,9 @@ test_choose_walks_what_it_cannot_read_directly :: proc(t: ^testing.T) {
// what makes a target walkable, and `resolve` sets one for everything that has a
// directory tree behind it.
for fs in ([]Filesystem{.Refs, .Exfat, .Fat32, .Network, .Other}) {
- c := choose(Target{fs = fs, volume = `\\.\D:`, mount = `D:\`}, elevated = true)
+ c := choose(Target{fs = fs, volume = `\\.\D:`, mount = `D:\`}, readable = true)
testing.expect_value(t, c.engine, Engine.Walk)
- testing.expect(t, !c.elevation_would_help)
+ testing.expect(t, !c.permission_would_help)
}
}
@@ -38,7 +38,7 @@ test_choose_reads_an_image_without_elevation :: proc(t: ^testing.T) {
volume = "disk.img",
image = true,
}
- testing.expect_value(t, choose(image, elevated = false).engine, Engine.Mft)
+ testing.expect_value(t, choose(image, readable = true).engine, Engine.Mft)
}
@(test)
@@ -46,13 +46,13 @@ test_choose_gives_up_on_a_volume_it_cannot_name :: proc(t: ^testing.T) {
// A raw device or an image whose filesystem went unrecognised has no tree to
// walk, so offering the walker would report an empty volume as an answer.
for v in ([]Target{{volume = "/dev/sda1"}, {volume = "disk.img", image = true}}) {
- testing.expect_value(t, choose(v, elevated = true).engine, Engine.None)
+ testing.expect_value(t, choose(v, readable = true).engine, Engine.None)
}
}
@(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)
+ testing.expect_value(t, choose(Target{}, readable = true).engine, Engine.None)
}
@(test)
@@ -62,7 +62,7 @@ test_resolve_finds_the_volume_behind_a_path :: proc(t: ^testing.T) {
}
// The system drive is the one target every Windows machine has, and reading its
// name needs no privilege.
- root, err := resolve("C:")
+ root, _, err := resolve("C:")
testing.expect_value(t, err, Error.None)
defer target_destroy(&root)
testing.expect_value(t, root.mount, `C:\`)
@@ -70,7 +70,7 @@ test_resolve_finds_the_volume_behind_a_path :: proc(t: ^testing.T) {
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`)
+ 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)
@@ -83,6 +83,6 @@ test_resolve_rejects_what_is_not_there :: proc(t: ^testing.T) {
when ODIN_OS != .Windows {
return
}
- _, err := resolve(`Q:\no\such\path`)
+ _, _, 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
@@ -70,27 +70,32 @@ Engine :: enum {
}
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,
+ engine: Engine,
+ // The MFT reader is far faster but needs to open the volume raw. When this is
+ // set, Walk is what will run unless the caller obtains that and asks again.
+ permission_would_help: bool,
}
/*
Pick a reader for a target.
+`readable` says the volume opened for raw reading, which is the only thing standing
+between an NTFS target and the MFT reader. `resolve` finds it out and calls this; it
+is a separate procedure so the whole decision table can be tested without a disk, not
+because anything else needs to choose. An image needs no special case: it is a file,
+so it opens.
+
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 {
+@(private)
+choose :: proc(t: Target, readable: bool) -> Choice {
if t.fs == .Ntfs {
- // An image is an ordinary file, so reading one needs no more than opening it.
- // It is the raw handle onto a mounted volume that has to be granted.
- if elevated || t.image {
+ if readable {
return {engine = .Mft}
}
- return {engine = .Walk, elevation_would_help = true}
+ return {engine = .Walk, permission_would_help = true}
}
// Nothing mounted means no directory tree to enumerate. A raw volume and an image
// are both bytes until a reader that knows the filesystem inside says otherwise,
diff --git a/scan/target_other.odin b/scan/target_other.odin
@@ -15,14 +15,25 @@ A directory names a place inside a volume, which the mount table accounts for.
Reading a signature is not parsing a filesystem: saying which reader applies is this
layer's job, and doing it here keeps that job free of every backend, exactly as
asking Windows for a volume's filesystem name does.
+
+The reader comes back with the target. Choosing one needs to know whether the volume
+opens, which is found out here, so leaving the caller to ask separately would only
+mean opening it twice.
*/
-resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, err: Error) {
+resolve :: proc(
+ input: string,
+ allocator := context.allocator,
+) -> (
+ t: Target,
+ c: Choice,
+ err: Error,
+) {
info, stat_err := os.stat(input, context.temp_allocator)
if stat_err != nil {
if stat_err == io.Error.Permission_Denied {
- return {}, .Access_Denied
+ return {}, {}, .Access_Denied
}
- return {}, .Target_Not_Found
+ return {}, {}, .Target_Not_Found
}
path := info.fullpath
@@ -36,16 +47,17 @@ resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, er
fs, sig_err := signature(path)
if sig_err != .None {
target_destroy(&t)
- return {}, sig_err
+ return {}, {}, sig_err
}
t.fs = fs
- return t, .None
+ // Reading the signature opened it, so there is nothing left to find out.
+ return t, choose(t, true), .None
case .Directory:
device, mount, fs, found := mounted_at(path, allocator)
if !found {
target_destroy(&t)
- return {}, .Unsupported_Platform
+ return {}, {}, .Unsupported_Platform
}
t.volume = device
t.mount = mount
@@ -55,22 +67,29 @@ resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, er
if len(path) > len(mount) {
t.root = strings.clone(path[len(mount):], allocator)
}
- return t, .None
+ // Reached through its mount point, so nothing so far has touched the device.
+ // Whether that opens is what decides between reading the filesystem whole and
+ // walking what is mounted from it.
+ return t, choose(t, can_open(device)), .None
}
target_destroy(&t)
- return {}, .Target_Not_Found
+ return {}, {}, .Target_Not_Found
}
-/*
-Whether this process can open a raw volume handle.
-
-Always true by the time it is asked. Unix grants raw reads through permissions on the
-device rather than a process token, and `resolve` only names a filesystem for a
-volume whose front it managed to read, so a target that reached here already opened.
-A volume that did not fails resolution with Access_Denied instead.
-*/
-elevated :: proc() -> bool {
+// Whether a volume opens for raw reading. Unix keeps that answer in the device's
+// permissions and the groups held rather than in the process, so opening it is the
+// only test that covers every reason it might be refused.
+@(private = "file")
+can_open :: proc(path: string) -> bool {
+ if path == "" {
+ return false
+ }
+ f, err := os.open(path, {.Read})
+ if err != nil {
+ return false
+ }
+ os.close(f)
return true
}
@@ -141,8 +160,10 @@ mounted_at :: proc(
if read_err != nil {
return "", "", .Unknown, false
}
+
best_device, best_mount, best_type: string
text := string(table)
+
for line in strings.split_lines_iterator(&text) {
fields := strings.fields(line, context.temp_allocator)
if len(fields) < 3 {
@@ -154,13 +175,17 @@ mounted_at :: proc(
}
best_device, best_mount, best_type = fields[0], point, fields[2]
}
+
if best_mount == "" {
return "", "", .Unknown, false
}
- return strings.clone(best_device, allocator),
- strings.clone(best_mount, allocator),
- filesystem_from_name(best_type),
- true
+
+ device = strings.clone(best_device, allocator)
+ mount = strings.clone(best_mount, allocator)
+ fs = filesystem_from_name(best_type)
+ found = true
+
+ return device, mount, fs, found
}
}
diff --git a/scan/target_windows.odin b/scan/target_windows.odin
@@ -11,7 +11,6 @@ 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 ---
}
/*
@@ -20,8 +19,19 @@ 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.
+
+The reader comes back with the target. Choosing one needs to know whether the volume
+opens, which is found out here, so leaving the caller to ask separately would only
+mean opening it twice.
*/
-resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, err: Error) {
+resolve :: proc(
+ input: string,
+ allocator := context.allocator,
+) -> (
+ t: Target,
+ c: Choice,
+ err: Error,
+) {
t.allocator = allocator
t.input = strings.clone(input, allocator)
@@ -34,7 +44,7 @@ resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, er
attrs := win.GetFileAttributesW(wpath)
if attrs == win.INVALID_FILE_ATTRIBUTES {
target_destroy(&t)
- return {}, .Target_Not_Found
+ return {}, {}, .Target_Not_Found
}
// A file that is not a directory is taken to be an image of a volume. Nothing
@@ -42,7 +52,7 @@ resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, er
if attrs & win.FILE_ATTRIBUTE_DIRECTORY == 0 {
t.image = true
t.volume = strings.clone(path, allocator)
- return t, .None
+ return t, choose(t, can_open(t.volume)), .None
}
// The mount point tells us which volume the path sits on, which is the only way
@@ -50,12 +60,12 @@ resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, er
mount_buf: [win.MAX_PATH]u16
if !GetVolumePathNameW(wpath, &mount_buf[0], len(mount_buf)) {
target_destroy(&t)
- return {}, .Target_Not_Found
+ 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
+ return {}, {}, .Out_Of_Memory
}
t.mount = mount
@@ -89,7 +99,9 @@ resolve :: proc(input: string, allocator := context.allocator) -> (t: Target, er
if len(path) > len(t.mount) {
t.root = strings.clone(path[len(t.mount) - 1:], allocator)
}
- return t, .None
+ // The mount point answered everything above; nothing has touched the volume
+ // itself. Whether that opens is what decides the reader.
+ return t, choose(t, can_open(t.volume)), .None
}
@(private)
@@ -127,24 +139,32 @@ is_drive_spec :: proc(s: string) -> bool {
}
/*
-Whether this process can open a raw volume handle.
+Whether a volume opens for raw reading.
+
+Elevation is the usual reason a raw handle is refused, but an elevated token still
+says nothing about a volume that is missing, or locked by something else. Opening it
+answers all of those at once and costs one 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.
+FILE_SHARE_WRITE is required: the volume is mounted and in use, so asking to exclude
+writers would fail for a reason that has nothing to do with permission.
*/
-elevated :: proc() -> bool {
- token: win.HANDLE
- if !win.OpenProcessToken(GetCurrentProcess(), win.TOKEN_QUERY, &token) {
+@(private = "file")
+can_open :: proc(path: string) -> bool {
+ if path == "" {
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) {
+ h := win.CreateFileW(
+ win.utf8_to_wstring(path, context.temp_allocator),
+ win.GENERIC_READ,
+ win.FILE_SHARE_READ | win.FILE_SHARE_WRITE | win.FILE_SHARE_DELETE,
+ nil,
+ win.OPEN_EXISTING,
+ 0,
+ nil,
+ )
+ if h == win.INVALID_HANDLE_VALUE {
return false
}
- return value != 0
+ win.CloseHandle(h)
+ return true
}