osbase/structfs: add package
This adds the structfs package, which defines a data structure for a
file system and a blob interface.
Change-Id: I646205faf7d28ec016d2038b0a8931b64e7afc83
Reviewed-on: https://review.monogon.dev/c/monogon/+/4036
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/osbase/structfs/structfs_test.go b/osbase/structfs/structfs_test.go
new file mode 100644
index 0000000..e28f9c5
--- /dev/null
+++ b/osbase/structfs/structfs_test.go
@@ -0,0 +1,300 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package structfs_test
+
+import (
+ "errors"
+ "io"
+ "io/fs"
+ "slices"
+ "syscall"
+ "testing"
+ "time"
+
+ . "source.monogon.dev/osbase/structfs"
+)
+
+func TestOptions(t *testing.T) {
+ testTimestamp := time.Date(2022, 03, 04, 5, 6, 8, 0, time.UTC)
+ var tree Tree
+ tree.PlaceDir("dir", Tree{},
+ WithModTime(testTimestamp),
+ WithPerm(0o700|fs.ModeSetuid|fs.ModeDevice),
+ WithSys("fakesys"),
+ )
+ node := tree[0]
+ if node.ModTime != testTimestamp {
+ t.Errorf("Got ModTime %v, expected %v", node.ModTime, testTimestamp)
+ }
+ expectMode := 0o700 | fs.ModeSetuid | fs.ModeDir
+ if node.Mode != expectMode {
+ t.Errorf("Got Mode %s, expected %s", node.Mode, expectMode)
+ }
+ if node.Sys != "fakesys" {
+ t.Errorf("Got Sys %v, expected %v", node.Sys, "fakesys")
+ }
+}
+
+func treeToStrings(t *testing.T, tree Tree) []string {
+ var out []string
+ for path, node := range tree.Walk() {
+ s := path + " " + node.Mode.String()[:1]
+ if node.Mode.IsRegular() {
+ content, err := node.Content.Open()
+ if err != nil {
+ t.Errorf("Failed to open %q: %v", path, err)
+ continue
+ }
+ b, err := io.ReadAll(content)
+ if err != nil {
+ t.Errorf("Failed to read %q: %v", path, err)
+ continue
+ }
+ s += " " + string(b)
+ content.Close()
+ }
+ out = append(out, s)
+ }
+ return out
+}
+
+func TestWalk(t *testing.T) {
+ testCases := []struct {
+ desc string
+ tree Tree
+ expected []string
+ }{
+ {
+ desc: "example",
+ tree: Tree{
+ File("file1a", Bytes("content1a")),
+ Dir("dir1", Tree{
+ File("file2", Bytes("content2")),
+ Dir("dir2", nil),
+ }),
+ File("file1b", Bytes("content1b")),
+ },
+ expected: []string{
+ "file1a - content1a",
+ "dir1 d",
+ "dir1/file2 - content2",
+ "dir1/dir2 d",
+ "file1b - content1b",
+ },
+ },
+ {
+ desc: "empty",
+ tree: nil,
+ expected: nil,
+ },
+ {
+ desc: "ignore file children",
+ // Non-directories should not have children and Walk should ignore them.
+ tree: Tree{{
+ Name: "file1",
+ Content: Bytes("content1"),
+ Children: Tree{
+ File("file2", Bytes("content2")),
+ Dir("dir2", nil),
+ },
+ }},
+ expected: []string{
+ "file1 - content1",
+ },
+ },
+ {
+ desc: "skip invalid name",
+ tree: Tree{
+ File("", Bytes("invalid")),
+ File(".", Bytes("invalid")),
+ File("a/b", Bytes("invalid")),
+ File("file1a", Bytes("content1a")),
+ Dir("..", Tree{
+ File("file2", Bytes("content2")),
+ Dir("dir2", nil),
+ }),
+ File("file1b", Bytes("content1b")),
+ },
+ expected: []string{
+ "file1a - content1a",
+ "file1b - content1b",
+ },
+ },
+ }
+ for _, tC := range testCases {
+ t.Run(tC.desc, func(t *testing.T) {
+ actual := treeToStrings(t, tC.tree)
+ if !slices.Equal(actual, tC.expected) {
+ t.Errorf("Walk result %v differs from expected %v", actual, tC.expected)
+ }
+ })
+ }
+}
+
+func TestPlace(t *testing.T) {
+ testCases := []struct {
+ desc string
+ tree Tree
+ place func(*Tree) error
+ expected []string
+ err error
+ errPath string
+ }{
+ {
+ desc: "file",
+ tree: Tree{
+ File("file1a", Bytes("content1a")),
+ Dir("dir1", Tree{
+ File("file2", Bytes("content2")),
+ Dir("dir2", nil),
+ }),
+ File("file1b", Bytes("content1b")),
+ },
+ place: func(tree *Tree) error {
+ return tree.PlaceFile("dir1/dir3/file4", Bytes("content4"))
+ },
+ expected: []string{
+ "file1a - content1a",
+ "dir1 d",
+ "dir1/file2 - content2",
+ "dir1/dir2 d",
+ "dir1/dir3 d",
+ "dir1/dir3/file4 - content4",
+ "file1b - content1b",
+ },
+ },
+ {
+ desc: "dir",
+ tree: Tree{
+ File("file1a", Bytes("content1a")),
+ Dir("dir1", Tree{
+ File("file2", Bytes("content2")),
+ }),
+ },
+ place: func(tree *Tree) error {
+ return tree.PlaceDir("dir1/dir3", Tree{
+ File("file4", Bytes("content4")),
+ Dir("dir4", nil),
+ })
+ },
+ expected: []string{
+ "file1a - content1a",
+ "dir1 d",
+ "dir1/file2 - content2",
+ "dir1/dir3 d",
+ "dir1/dir3/file4 - content4",
+ "dir1/dir3/dir4 d",
+ },
+ },
+ {
+ desc: "empty",
+ tree: nil,
+ place: func(tree *Tree) error {
+ return tree.PlaceFile("dir1/dir2/file3", Bytes("content"))
+ },
+ expected: []string{
+ "dir1 d",
+ "dir1/dir2 d",
+ "dir1/dir2/file3 - content",
+ },
+ },
+ {
+ desc: "root",
+ tree: Tree{
+ File("file1", Bytes("content1")),
+ },
+ place: func(tree *Tree) error {
+ return tree.PlaceFile("file2", Bytes("content2"))
+ },
+ expected: []string{
+ "file1 - content1",
+ "file2 - content2",
+ },
+ },
+ {
+ desc: "invalid path",
+ place: func(tree *Tree) error {
+ return tree.PlaceFile(".", Bytes("content"))
+ },
+ err: fs.ErrInvalid,
+ errPath: ".",
+ },
+ {
+ desc: "not a directory",
+ tree: Tree{
+ Dir("dir1", Tree{
+ File("file2", Bytes("content")),
+ }),
+ },
+ place: func(tree *Tree) error {
+ return tree.PlaceFile("dir1/file2/dir3/file4", Bytes("content"))
+ },
+ err: syscall.ENOTDIR,
+ errPath: "dir1/file2",
+ },
+ {
+ desc: "already exists",
+ tree: Tree{
+ Dir("dir1", Tree{
+ Dir("dir2", nil),
+ }),
+ },
+ place: func(tree *Tree) error {
+ return tree.PlaceDir("dir1/dir2", nil)
+ },
+ err: fs.ErrExist,
+ errPath: "dir1/dir2",
+ },
+ }
+ for _, tC := range testCases {
+ t.Run(tC.desc, func(t *testing.T) {
+ err := tC.place(&tC.tree)
+ if err != nil {
+ if tC.err == nil {
+ t.Fatalf("Place failed unexpectedly: %v", err)
+ }
+ if !errors.Is(err, tC.err) {
+ t.Errorf("Place failed with error %v, expected %v", err, tC.err)
+ }
+ var pe *fs.PathError
+ if !errors.As(err, &pe) {
+ t.Fatalf("Place(): error is %T, want *fs.PathError", err)
+ }
+ if pe.Path != tC.errPath {
+ t.Errorf("Place(): err.Path = %q, want %q", pe.Path, tC.errPath)
+ }
+ } else if tC.err != nil {
+ t.Error("Expected place to fail but it did not")
+ } else {
+ actual := treeToStrings(t, tC.tree)
+ if !slices.Equal(actual, tC.expected) {
+ t.Errorf("Result %v differs from expected %v", actual, tC.expected)
+ }
+ }
+ })
+ }
+}
+
+func TestValidName(t *testing.T) {
+ isValidNameTests := []struct {
+ name string
+ ok bool
+ }{
+ {"x", true},
+ {"", false},
+ {"..", false},
+ {".", false},
+ {"x/y", false},
+ {"/", false},
+ {"x/", false},
+ {"/x", false},
+ {`x\y`, true},
+ }
+ for _, tt := range isValidNameTests {
+ ok := ValidName(tt.name)
+ if ok != tt.ok {
+ t.Errorf("ValidName(%q) = %v, want %v", tt.name, ok, tt.ok)
+ }
+ }
+}