aboutsummaryrefslogtreecommitdiff
path: root/internal/format
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-12 05:12:37 +0000
committerGravatar Runxi Yu2026-06-12 05:12:37 +0000
commitea5467c799ce4d1088f352ae600b43d4dd17147b (patch)
tree9b49a91282532398b62cba8eee52c581050c8544 /internal/format
parentinternal/format/packidx: Use repo.SeedHistory (diff)
internal/format/packrev: Add tests
Diffstat (limited to 'internal/format')
-rw-r--r--internal/format/packrev/helpers_test.go75
-rw-r--r--internal/format/packrev/packrev_test.go160
-rw-r--r--internal/format/packrev/write_test.go135
3 files changed, 370 insertions, 0 deletions
diff --git a/internal/format/packrev/helpers_test.go b/internal/format/packrev/helpers_test.go
new file mode 100644
index 00000000..2ec2f434
--- /dev/null
+++ b/internal/format/packrev/helpers_test.go
@@ -0,0 +1,75 @@
+package packrev_test
+
+import (
+ "cmp"
+ "slices"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/format/packidx"
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/lgo/intconv"
+)
+
+// makeGitPack seeds a repository,
+// packs the seeded objects with git pack-objects
+// including a reverse index,
+// and returns the artifact path prefix.
+func makeGitPack(t *testing.T, objectFormat id.ObjectFormat) string {
+ t.Helper()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ seeded, err := repo.SeedHistory(t)
+ if err != nil {
+ t.Fatalf("SeedHistory: %v", err)
+ }
+
+ prefix, err := repo.PackObjects(t, slices.Values(seeded.All()), testgit.PackObjectsOptions{RevIndex: true})
+ if err != nil {
+ t.Fatalf("PackObjects: %v", err)
+ }
+
+ return prefix
+}
+
+// packOrderPositions derives the pack-offset-order index positions
+// from one parsed pack index.
+func packOrderPositions(t *testing.T, idx *packidx.Packidx) []uint32 {
+ t.Helper()
+
+ type pair struct {
+ offset uint64
+ position uint32
+ }
+
+ pairs := make([]pair, 0, idx.NumObjects())
+
+ for pos := range idx.NumObjects() {
+ offset, err := idx.OffsetAt(pos)
+ if err != nil {
+ t.Fatalf("OffsetAt(%d): %v", pos, err)
+ }
+
+ position, err := intconv.IntToUint32(pos)
+ if err != nil {
+ t.Fatalf("IntToUint32(%d): %v", pos, err)
+ }
+
+ pairs = append(pairs, pair{offset: offset, position: position})
+ }
+
+ slices.SortFunc(pairs, func(a, b pair) int {
+ return cmp.Compare(a.offset, b.offset)
+ })
+
+ positions := make([]uint32, 0, len(pairs))
+ for _, p := range pairs {
+ positions = append(positions, p.position)
+ }
+
+ return positions
+}
diff --git a/internal/format/packrev/packrev_test.go b/internal/format/packrev/packrev_test.go
new file mode 100644
index 00000000..b644e15e
--- /dev/null
+++ b/internal/format/packrev/packrev_test.go
@@ -0,0 +1,160 @@
+package packrev_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "os"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/format/packidx"
+ "lindenii.org/go/furgit/internal/format/packrev"
+ "lindenii.org/go/furgit/object/id"
+)
+
+func TestParseGitReverseIndex(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ prefix := makeGitPack(t, objectFormat)
+
+ revData, err := os.ReadFile(prefix + ".rev") //nolint:gosec
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+
+ rev, err := packrev.Parse(revData, objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ idxData, err := os.ReadFile(prefix + ".idx") //nolint:gosec
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+
+ idx, err := packidx.Parse(idxData, objectFormat.Size())
+ if err != nil {
+ t.Fatalf("packidx.Parse: %v", err)
+ }
+
+ if rev.NumObjects() != idx.NumObjects() {
+ t.Fatalf("NumObjects = %d, want %d", rev.NumObjects(), idx.NumObjects())
+ }
+
+ packData, err := os.ReadFile(prefix + ".pack") //nolint:gosec
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+
+ packTrailer := packData[len(packData)-objectFormat.Size():]
+ if !bytes.Equal(rev.PackHash(), packTrailer) {
+ t.Fatalf("PackHash does not match pack trailer")
+ }
+
+ want := packOrderPositions(t, &idx)
+
+ for packOrder, wantPosition := range want {
+ position, err := rev.PositionAt(packOrder)
+ if err != nil {
+ t.Fatalf("PositionAt(%d): %v", packOrder, err)
+ }
+
+ if position != int(wantPosition) {
+ t.Fatalf("PositionAt(%d) = %d, want %d", packOrder, position, wantPosition)
+ }
+ }
+ })
+ }
+}
+
+func TestParseMalformed(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ valid := writeSyntheticRev(t, objectFormat, []uint32{2, 0, 1, 3})
+
+ corrupt := func(mutate func(data []byte) []byte) []byte {
+ return mutate(bytes.Clone(valid))
+ }
+
+ cases := []struct {
+ name string
+ data []byte
+ }{
+ {name: "empty", data: []byte{}},
+ {name: "truncated", data: corrupt(func(d []byte) []byte { return d[:10] })},
+ {
+ name: "bad signature",
+ data: corrupt(func(d []byte) []byte {
+ d[0] ^= 0xff
+
+ return d
+ }),
+ },
+ {
+ name: "bad version",
+ data: corrupt(func(d []byte) []byte {
+ d[7] = 2
+
+ return d
+ }),
+ },
+ {
+ name: "hash function mismatch",
+ data: corrupt(func(d []byte) []byte {
+ d[11] ^= 0xff
+
+ return d
+ }),
+ },
+ {
+ name: "position table size not 32-bit multiple",
+ data: corrupt(func(d []byte) []byte { return append(d, 0xde, 0xad) }),
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := packrev.Parse(tc.data, objectFormat)
+ if !errors.Is(err, packrev.ErrMalformedReverseIndex) {
+ t.Fatalf("Parse error = %v, want ErrMalformedReverseIndex", err)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestPositionAtMalformed(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ data := writeSyntheticRev(t, objectFormat, []uint32{2, 0, 1, 3})
+
+ // Corrupt the first stored position to one past the object count.
+ binary.BigEndian.PutUint32(data[12:], 4)
+
+ rev, err := packrev.Parse(data, objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ _, err = rev.PositionAt(0)
+ if !errors.Is(err, packrev.ErrMalformedReverseIndex) {
+ t.Fatalf("PositionAt error = %v, want ErrMalformedReverseIndex", err)
+ }
+ })
+ }
+}
diff --git a/internal/format/packrev/write_test.go b/internal/format/packrev/write_test.go
new file mode 100644
index 00000000..b5c1fcb9
--- /dev/null
+++ b/internal/format/packrev/write_test.go
@@ -0,0 +1,135 @@
+package packrev_test
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/format/packidx"
+ "lindenii.org/go/furgit/internal/format/packrev"
+ "lindenii.org/go/furgit/object/id"
+)
+
+// writeSyntheticRev writes one reverse index over positions
+// with a fixed fake pack hash.
+func writeSyntheticRev(t *testing.T, objectFormat id.ObjectFormat, positions []uint32) []byte {
+ t.Helper()
+
+ packHash := bytes.Repeat([]byte{0x5a}, objectFormat.Size())
+
+ var buf bytes.Buffer
+
+ err := packrev.Write(&buf, objectFormat, positions, packHash)
+ if err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+
+ return buf.Bytes()
+}
+
+func TestWriteRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ positions := []uint32{8, 6, 7, 5, 3, 0, 4, 1, 2}
+ data := writeSyntheticRev(t, objectFormat, positions)
+
+ rev, err := packrev.Parse(data, objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ if rev.NumObjects() != len(positions) {
+ t.Fatalf("NumObjects = %d, want %d", rev.NumObjects(), len(positions))
+ }
+
+ if !bytes.Equal(rev.PackHash(), bytes.Repeat([]byte{0x5a}, objectFormat.Size())) {
+ t.Fatalf("PackHash mismatch")
+ }
+
+ for packOrder, want := range positions {
+ position, err := rev.PositionAt(packOrder)
+ if err != nil {
+ t.Fatalf("PositionAt(%d): %v", packOrder, err)
+ }
+
+ if position != int(want) {
+ t.Fatalf("PositionAt(%d) = %d, want %d", packOrder, position, want)
+ }
+ }
+ })
+ }
+}
+
+func TestWriteMatchesGit(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ prefix := makeGitPack(t, objectFormat)
+
+ gitData, err := os.ReadFile(prefix + ".rev") //nolint:gosec
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+
+ idxData, err := os.ReadFile(prefix + ".idx") //nolint:gosec
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+
+ idx, err := packidx.Parse(idxData, objectFormat.Size())
+ if err != nil {
+ t.Fatalf("packidx.Parse: %v", err)
+ }
+
+ positions := packOrderPositions(t, &idx)
+
+ var buf bytes.Buffer
+
+ err = packrev.Write(&buf, objectFormat, positions, idx.PackHash())
+ if err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+
+ if !bytes.Equal(buf.Bytes(), gitData) {
+ t.Fatalf("Write output differs from git's reverse index (%d vs %d bytes)", buf.Len(), len(gitData))
+ }
+ })
+ }
+}
+
+func TestWriteInvalidPositions(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ packHash := bytes.Repeat([]byte{0x5a}, objectFormat.Size())
+
+ err := packrev.Write(&bytes.Buffer{}, objectFormat, []uint32{0, 5}, packHash)
+ if !errors.Is(err, packrev.ErrInvalidPositions) {
+ t.Fatalf("Write error = %v, want ErrInvalidPositions", err)
+ }
+ })
+ }
+}
+
+func TestWriteBadPackHashPanics(t *testing.T) {
+ t.Parallel()
+
+ defer func() {
+ if recover() == nil {
+ t.Fatalf("Write with short pack hash: expected panic")
+ }
+ }()
+
+ _ = packrev.Write(&bytes.Buffer{}, id.ObjectFormatSHA256, nil, []byte{0x01})
+}