m/p/gpt: add GPT package

This introduces our own GPT package. It will be used for provisioning
and Metropolis images.

Change-Id: I905cd5d540673fd4b69c01d8975f98b88e24edd4
Reviewed-on: https://review.monogon.dev/c/monogon/+/956
Tested-by: Jenkins CI
Reviewed-by: Sergiusz Bazanski <serge@monogon.tech>
diff --git a/metropolis/pkg/gpt/gpt_test.go b/metropolis/pkg/gpt/gpt_test.go
new file mode 100644
index 0000000..df2970b
--- /dev/null
+++ b/metropolis/pkg/gpt/gpt_test.go
@@ -0,0 +1,152 @@
+package gpt
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"io"
+	"os"
+	"testing"
+
+	"github.com/google/uuid"
+)
+
+func TestUUIDTranspose(t *testing.T) {
+	testUUID := uuid.MustParse("00112233-4455-6677-c899-aabbccddeeff")
+	mixedEndianUUID := mangleUUID(testUUID)
+	expectedMixedEndianUUID := [16]byte{0x33, 0x22, 0x11, 0x00, 0x55, 0x44, 0x77, 0x66, 0xc8, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}
+	if mixedEndianUUID != expectedMixedEndianUUID {
+		t.Errorf("mangleUUID(%s) = %x, expected %x", testUUID, mixedEndianUUID, expectedMixedEndianUUID)
+	}
+	roundTrippedUUID := unmangleUUID(mixedEndianUUID)
+	if testUUID != roundTrippedUUID {
+		t.Errorf("unmangleUUID(mangleUUID(%s)) = %s, expected input", testUUID, roundTrippedUUID)
+	}
+}
+
+func TestFreeSpaces(t *testing.T) {
+	cases := []struct {
+		name            string
+		parts           []*Partition
+		expected        [][2]int64
+		expectedOverlap bool
+	}{
+		{"Empty", []*Partition{}, [][2]int64{{34, 2015}}, false},
+		{"OnePart", []*Partition{
+			{Type: PartitionTypeEFISystem, FirstBlock: 200, LastBlock: 1499},
+		}, [][2]int64{
+			{34, 200},
+			{1500, 2015},
+		}, false},
+		{"TwoOverlappingParts", []*Partition{
+			{Type: PartitionTypeEFISystem, FirstBlock: 200, LastBlock: 1499},
+			{Type: PartitionTypeEFISystem, FirstBlock: 1000, LastBlock: 1999},
+		}, [][2]int64{
+			{34, 200},
+			{2000, 2015},
+		}, true},
+		{"Full", []*Partition{
+			{Type: PartitionTypeEFISystem, FirstBlock: 34, LastBlock: 999},
+			{Type: PartitionTypeEFISystem, FirstBlock: 1000, LastBlock: 2014},
+		}, [][2]int64{}, false},
+		{"TwoSpacedParts", []*Partition{
+			{Type: PartitionTypeEFISystem, FirstBlock: 500, LastBlock: 899},
+			{Type: PartitionTypeEFISystem, FirstBlock: 1200, LastBlock: 1799},
+		}, [][2]int64{
+			{34, 500},
+			{900, 1200},
+			{1800, 2015},
+		}, false},
+	}
+
+	// Partitions are created manually as AddPartition calls FreeSpaces itself,
+	// which makes the test unreliable as well as making failures very hard to
+	// debug.
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			g := Table{
+				BlockSize:  512,
+				BlockCount: 2048, // 0.5KiB * 2048 = 1MiB
+				Partitions: c.parts,
+			}
+			fs, overlap, err := g.GetFreeSpaces()
+			if err != nil {
+				t.Fatal(err)
+			}
+			if overlap != c.expectedOverlap {
+				t.Errorf("expected overlap %v, got %v", c.expectedOverlap, overlap)
+			}
+			if len(fs) != len(c.expected) {
+				t.Fatalf("expected %v, got %v", c.expected, fs)
+			}
+			for i := range fs {
+				if fs[i] != c.expected[i] {
+					t.Errorf("free space mismatch at pos %d: got [%d, %d), expected [%d, %d)", i, fs[i][0], fs[i][1], c.expected[i][0], c.expected[i][1])
+				}
+			}
+		})
+	}
+}
+
+func TestRoundTrip(t *testing.T) {
+	if os.Getenv("IN_KTEST") == "true" {
+		t.Skip("In ktest")
+	}
+	g := Table{
+		ID:         uuid.NewSHA1(zeroUUID, []byte("test")),
+		BlockSize:  512,
+		BlockCount: 2048,
+		BootCode:   []byte("just some test code"),
+		Partitions: []*Partition{
+			nil,
+			// This emoji is very complex and exercises UTF16 surrogate encoding
+			// and composing.
+			{Name: "Test 🏃‍♂️", FirstBlock: 10, LastBlock: 19, Type: PartitionTypeEFISystem, ID: uuid.NewSHA1(zeroUUID, []byte("test1")), Attributes: AttrNoBlockIOProto},
+			nil,
+			{Name: "Test2", FirstBlock: 20, LastBlock: 49, Type: PartitionTypeEFISystem, ID: uuid.NewSHA1(zeroUUID, []byte("test2")), Attributes: 0},
+		},
+	}
+	f, err := os.CreateTemp("", "")
+	if err != nil {
+		t.Fatalf("Failed to create temporary file: %v", err)
+	}
+	defer os.Remove(f.Name())
+	if err := Write(f, &g); err != nil {
+		t.Fatalf("Error while wrinting Table: %v", err)
+	}
+	f.Seek(0, io.SeekStart)
+	originalHash := sha256.New()
+	if _, err := io.Copy(originalHash, f); err != nil {
+		panic(err)
+	}
+
+	g2, err := Read(f, 512, 2048)
+	if err != nil {
+		t.Fatalf("Failed to read back GPT: %v", err)
+	}
+	if g2.ID != g.ID {
+		t.Errorf("Disk UUID changed when reading back: %v", err)
+	}
+	// Destroy primary GPT
+	f.Seek(1*g.BlockSize, io.SeekStart)
+	f.Write(make([]byte, 512))
+	f.Seek(0, io.SeekStart)
+	g3, err := Read(f, 512, 2048)
+	if err != nil {
+		t.Fatalf("Failed to read back GPT with primary GPT destroyed: %v", err)
+	}
+	if g3.ID != g.ID {
+		t.Errorf("Disk UUID changed when reading back: %v", err)
+	}
+	f.Seek(0, io.SeekStart)
+	if err := Write(f, g3); err != nil {
+		t.Fatalf("Error while writing back GPT: %v", err)
+	}
+	f.Seek(0, io.SeekStart)
+	rewrittenHash := sha256.New()
+	if _, err := io.Copy(rewrittenHash, f); err != nil {
+		panic(err)
+	}
+	if !bytes.Equal(originalHash.Sum([]byte{}), rewrittenHash.Sum([]byte{})) {
+		t.Errorf("Write/Read/Write test was not reproducible: %x != %x", originalHash.Sum([]byte{}), rewrittenHash.Sum([]byte{}))
+	}
+}