commit f2ded2b7f07f5ce56156cc841a8acb74bba14244
parent 90ccc9b0f4c1ab0d69919657d991d6b4fa842730
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 09:49:39 -0300
walk: test what a walk records
The package had no tests, which is how the linux reader shipped in draft opening
directories with NOFOLLOW: correct for a directory, and a refusal of exactly the
symlinks the caller had asked to follow.
One fixture, walked both ways, pins what a node means: every entry recorded once,
directories marked, a symlink left as a reparse until following is asked for, and
a cancelled walk stopping. The same tests pass against either reader, which is
what the seam is for. Allocation is the one answer that differs, so the hole is
tested where the exact answer lives.
Diffstat:
2 files changed, 224 insertions(+), 0 deletions(-)
diff --git a/walk/read_linux_test.odin b/walk/read_linux_test.odin
@@ -0,0 +1,71 @@
+#+build linux
+package walk
+
+import "core:fmt"
+import "core:os"
+import "core:slice"
+import "core:testing"
+
+import shared "../scan"
+
+/*
+A hole costs nothing and has to be charged nothing.
+
+This is the one answer that differs from the portable reader, which knows only the
+length and rounds it up to a guessed block. A file of mostly hole would be charged
+its whole length that way, which for a tool reporting what fills a disk is not a
+rounding error but the wrong file.
+*/
+@(test)
+test_a_sparse_file_is_charged_what_it_occupies :: proc(t: ^testing.T) {
+ dir, dir_err := os.temp_directory(context.temp_allocator)
+ testing.expect(t, dir_err == nil, "no temp directory")
+ root := fmt.tprintf("%s/sonar_walk_sparse", dir)
+ os.remove_all(root)
+ defer os.remove_all(root)
+ testing.expect(t, os.make_directory(root) == nil, "could not make the root")
+
+ // One megabyte of nothing at all.
+ hole, hole_err := os.open(fmt.tprintf("%s/hole.bin", root), {.Write, .Create, .Trunc})
+ testing.expect(t, hole_err == nil, "could not create the sparse file")
+ testing.expect(t, os.truncate(hole, 1 << 20) == nil, "could not extend the sparse file")
+ os.close(hole)
+
+ // The same length, actually written.
+ body := make([]byte, 1 << 20, context.temp_allocator)
+ slice.fill(body, 'x')
+ testing.expect(t, os.write_entire_file(fmt.tprintf("%s/solid.bin", root), body) == nil, "could not write")
+
+ tree: shared.Tree
+ testing.expect_value(t, shared.tree_init(&tree, 1), shared.Error.None)
+ defer shared.tree_destroy(&tree)
+ testing.expect_value(t, scan(root, &tree, Config{}), Error.None)
+
+ for i in 0 ..< tree.count {
+ n := shared.node(&tree, i)
+ switch n.name {
+ case "hole.bin":
+ testing.expect_value(t, n.size, u64(1 << 20))
+ testing.expectf(t, n.disk < 64 * 1024, "a hole was charged %d bytes", n.disk)
+ case "solid.bin":
+ testing.expect_value(t, n.size, u64(1 << 20))
+ testing.expectf(t, n.disk >= 1 << 20, "a full megabyte was charged %d bytes", n.disk)
+ }
+ }
+}
+
+// A path the kernel would refuse is refused here, rather than silently truncated to
+// one that names a different directory.
+@(test)
+test_a_path_too_long_is_refused :: proc(t: ^testing.T) {
+ buf: Path
+ long := make([]byte, len(buf), context.temp_allocator)
+ slice.fill(long, 'a')
+
+ _, ok := terminate(string(long), &buf)
+ testing.expect(t, !ok, "an over-long path was accepted")
+
+ fits, fits_ok := terminate("/usr/share", &buf)
+ testing.expect(t, fits_ok, "an ordinary path was refused")
+ testing.expect_value(t, string(fits), "/usr/share")
+}
diff --git a/walk/walk_test.odin b/walk/walk_test.odin
@@ -0,0 +1,153 @@
+package walk
+
+import "core:fmt"
+import "core:os"
+import "core:slice"
+import "core:testing"
+
+// This package's own entry point is called `scan` as well, so the import is
+// aliased to leave that name free to be called.
+import shared "../scan"
+
+/*
+Build a known tree and return its root.
+
+ <root>/a.txt 100 bytes
+ <root>/sub/b.txt 5000 bytes
+ <root>/sub/deep/c.txt 1 byte
+ <root>/link a symlink to sub
+
+Six entries below the root, one of which leads back into the tree, which is what
+makes following symlinks worth a decision rather than a default.
+*/
+@(private = "file")
+fixture :: proc(t: ^testing.T, name: string) -> string {
+ dir, dir_err := os.temp_directory(context.temp_allocator)
+ testing.expect(t, dir_err == nil, "no temp directory")
+ root := fmt.tprintf("%s/sonar_walk_%s", dir, name)
+ os.remove_all(root)
+
+ write :: proc(t: ^testing.T, path: string, size: int) {
+ body := make([]byte, size, context.temp_allocator)
+ slice.fill(body, 'x')
+ testing.expectf(t, os.write_entire_file(path, body) == nil, "could not write %s", path)
+ }
+ testing.expect(t, os.make_directory(root) == nil, "could not make the root")
+ testing.expect(t, os.make_directory(fmt.tprintf("%s/sub", root)) == nil, "could not make sub")
+ testing.expect(t, os.make_directory(fmt.tprintf("%s/sub/deep", root)) == nil, "could not make deep")
+ write(t, fmt.tprintf("%s/a.txt", root), 100)
+ write(t, fmt.tprintf("%s/sub/b.txt", root), 5000)
+ write(t, fmt.tprintf("%s/sub/deep/c.txt", root), 1)
+ testing.expect(t, os.symlink("sub", fmt.tprintf("%s/link", root)) == nil, "could not make the symlink")
+ return root
+}
+
+// Every node the walk recorded, by name. Names repeat across directories in general;
+// none of them do in the fixture.
+@(private = "file")
+named :: proc(tree: ^shared.Tree, name: string) -> (^shared.Node, int) {
+ found: ^shared.Node
+ count := 0
+ for i in 0 ..< tree.count {
+ n := shared.node(tree, i)
+ if .Used in n.flags && n.name == name {
+ found = n
+ count += 1
+ }
+ }
+ return found, count
+}
+
+@(private = "file")
+walked :: proc(t: ^testing.T, root: string, cfg := Config{}) -> shared.Tree {
+ tree: shared.Tree
+ testing.expect_value(t, shared.tree_init(&tree, 1), shared.Error.None)
+ testing.expect_value(t, scan(root, &tree, cfg), Error.None)
+ return tree
+}
+
+@(test)
+test_walk_records_every_entry :: proc(t: ^testing.T) {
+ root := fixture(t, "every")
+ defer os.remove_all(root)
+ tree := walked(t, root)
+ defer shared.tree_destroy(&tree)
+
+ // The root, plus the six entries below it.
+ testing.expect_value(t, tree.count, u32(7))
+ testing.expect_value(t, tree.nodes_done, u64(6))
+
+ for name in ([]string{"a.txt", "sub", "deep", "b.txt", "c.txt", "link"}) {
+ n, count := named(&tree, name)
+ testing.expectf(t, count == 1, "expected one %s, found %d", name, count)
+ if n != nil {
+ testing.expectf(t, .Settled in n.flags, "%s was left unsettled", name)
+ }
+ }
+}
+
+@(test)
+test_walk_marks_directories_and_sizes :: proc(t: ^testing.T) {
+ root := fixture(t, "shapes")
+ defer os.remove_all(root)
+ tree := walked(t, root)
+ defer shared.tree_destroy(&tree)
+
+ sub, _ := named(&tree, "sub")
+ testing.expect(t, .Directory in sub.flags, "sub is not marked a directory")
+ testing.expect_value(t, sub.disk, u64(0)) // a directory is charged through what it holds
+
+ a, _ := named(&tree, "a.txt")
+ testing.expect(t, .Directory not_in a.flags, "a.txt is marked a directory")
+ testing.expect_value(t, a.size, u64(100))
+ // 100 bytes occupy a block, whether the reader knows the block or estimates it.
+ testing.expectf(t, a.disk >= 512 && a.disk <= 8192, "a.txt charged %d bytes", a.disk)
+
+ b, _ := named(&tree, "b.txt")
+ testing.expect_value(t, b.size, u64(5000))
+ testing.expectf(t, b.disk >= 5000, "b.txt charged %d for 5000 bytes", b.disk)
+}
+
+@(test)
+test_walk_does_not_follow_a_symlink_by_default :: proc(t: ^testing.T) {
+ root := fixture(t, "nofollow")
+ defer os.remove_all(root)
+ tree := walked(t, root)
+ defer shared.tree_destroy(&tree)
+
+ link, _ := named(&tree, "link")
+ testing.expect(t, .Reparse in link.flags, "the symlink is not marked a reparse")
+ testing.expect(t, .Directory not_in link.flags, "the symlink is counted as a directory")
+ // Descending it would record sub's contents a second time.
+ _, subs := named(&tree, "b.txt")
+ testing.expect_value(t, subs, 1)
+}
+
+@(test)
+test_walk_follows_a_symlink_when_asked :: proc(t: ^testing.T) {
+ root := fixture(t, "follow")
+ defer os.remove_all(root)
+ tree := walked(t, root, Config{follow = true})
+ defer shared.tree_destroy(&tree)
+
+ // sub reached twice: once by name, once through the link. Its three entries
+ // therefore appear twice over.
+ _, b := named(&tree, "b.txt")
+ testing.expect_value(t, b, 2)
+ testing.expect_value(t, tree.count, u32(10))
+}
+
+@(test)
+test_walk_stops_when_cancelled :: proc(t: ^testing.T) {
+ root := fixture(t, "cancel")
+ defer os.remove_all(root)
+
+ tree: shared.Tree
+ testing.expect_value(t, shared.tree_init(&tree, 1), shared.Error.None)
+ defer shared.tree_destroy(&tree)
+
+ // Called off before it starts, so the seed directory is the only work offered
+ // and it declines it.
+ shared.cancel(&tree)
+ testing.expect_value(t, scan(root, &tree, Config{}), Error.Cancelled)
+}