m/p/fat32: add fat32 package

The fat32 package is a write-only implementation of the FAT32
filesystem. It works quite unlike a normal file system by first
determining the entire disk layout and then sequentially writing
out everything. This allows it to have a fully streaming output without
needing to seek at all.
Because all IO is sequential the implementation is extremely fast and
can potentially even leverage things like the copy_file_range syscall.
This means however that all files and readers need to be prepared ahead
of time, it is not possible to make decisions during the writing
process.
It is also possible to generate "right-sized" filesystems by not
specifying an explicit block count. In that case the resulting image
will contain exactly as many clusters as needed.

Change-Id: I49bf2ce09b26a7d628a39a0dd0745bca61c1c4da
Reviewed-on: https://review.monogon.dev/c/monogon/+/841
Tested-by: Jenkins CI
Reviewed-by: Sergiusz Bazanski <serge@monogon.tech>
diff --git a/metropolis/pkg/fat32/fsck_test.go b/metropolis/pkg/fat32/fsck_test.go
new file mode 100644
index 0000000..687504a
--- /dev/null
+++ b/metropolis/pkg/fat32/fsck_test.go
@@ -0,0 +1,103 @@
+package fat32
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+
+	"source.monogon.dev/metropolis/cli/pkg/datafile"
+)
+
+func testWithFsck(t *testing.T, rootInode Inode, opts Options) {
+	t.Helper()
+	fsckPath, err := datafile.ResolveRunfile("external/com_github_dosfstools_dosfstools/fsck")
+	if err != nil {
+		t.Fatalf("unable to get path to fsck: %v", err)
+	}
+	testFile, err := os.CreateTemp("", "fat32-fsck-test")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Remove(testFile.Name())
+	if err := WriteFS(testFile, rootInode, opts); err != nil {
+		t.Fatalf("failed to write test FS: %v", err)
+	}
+	// Run fsck non-interactively (-n), disallow spaces in short file names (-S)
+	// as well as perform deep verification (-V)
+	// If the file system is OK (i.e. fsck does not want to fix it) it returns
+	// 0, otherwise 1.
+	fsckCmd := exec.Command(fsckPath, "-n", "-S", "-V", testFile.Name())
+	result, err := fsckCmd.CombinedOutput()
+	if err != nil {
+		t.Errorf("fsck failed: %v", string(result))
+	}
+}
+
+func TestBasicFsck(t *testing.T) {
+	if os.Getenv("IN_KTEST") == "true" {
+		t.Skip("In ktest")
+	}
+	var largeString strings.Builder
+	for i := 0; i < 16384; i++ {
+		fmt.Fprintf(&largeString, "part%d", i)
+	}
+	// Test both common block sizes (512 and 4096 bytes) as well as the largest
+	// supported one (32K)
+	for _, blockSize := range []uint16{512, 4096, 32768} {
+		for _, fixed := range []string{"", "Fixed"} {
+			t.Run(fmt.Sprintf("BlockSize%d%v", blockSize, fixed), func(t *testing.T) {
+				rootInode := Inode{
+					Attrs:      AttrDirectory,
+					ModTime:    time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+					CreateTime: time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+				}
+				files := []struct {
+					name    string
+					path    string
+					content string
+				}{
+					{"FileInRoot", "test1.txt", "test1 content"},
+					{"LongFileInRoot", "verylongtest1.txt", "test1 content long"},
+					{"LongPath", "test1/test2/test3/test4/longdirname.ext/hello", "long path test content"},
+					{"LargeFile", "test1/largefile.txt", largeString.String()},
+				}
+				for _, c := range files {
+					err := rootInode.PlaceFile(c.path, strings.NewReader(c.content))
+					if err != nil {
+						t.Errorf("failed to place file: %v", err)
+					}
+				}
+				opts := Options{ID: 1234, Label: "TEST", BlockSize: blockSize}
+				if fixed == "Fixed" {
+					// Use a block count that is slightly higher than the minimum
+					opts.BlockCount = 67000
+				}
+				testWithFsck(t, rootInode, opts)
+			})
+		}
+	}
+}
+
+func TestLotsOfFilesFsck(t *testing.T) {
+	if os.Getenv("IN_KTEST") == "true" {
+		t.Skip("In ktest")
+	}
+	rootInode := Inode{
+		Attrs:   AttrDirectory,
+		ModTime: time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+	}
+	for i := 0; i < (32*1024)-2; i++ {
+		rootInode.Children = append(rootInode.Children, &Inode{
+			Name:    fmt.Sprintf("test%d", i),
+			Content: strings.NewReader("random test content"),
+			// Add some random attributes
+			Attrs: AttrHidden | AttrSystem,
+			// And a random ModTime
+			ModTime: time.Date(2022, 03, 04, 5, 6, 7, 8, time.UTC),
+		})
+	}
+	testWithFsck(t, rootInode, Options{ID: 1234, Label: "TEST"})
+}