aboutsummaryrefslogtreecommitdiff
path: root/object
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-08 13:01:49 +0000
committerGravatar Runxi Yu2026-06-08 13:01:49 +0000
commit7ca63924bf09525c3ee2be27521b009ef8c7783f (patch)
treed1628aa6e8523cf7d47b73f3a7ee9700577db732 /object
parentobject/store/loose: Add (diff)
signatureNo signature
object/store/loose: Add better tests
Diffstat (limited to 'object')
-rw-r--r--object/store/loose/helpers_test.go152
-rw-r--r--object/store/loose/loose_test.go26
-rw-r--r--object/store/loose/quarantine_test.go116
-rw-r--r--object/store/loose/read_test.go264
-rw-r--r--object/store/loose/roundtrip_test.go138
-rw-r--r--object/store/loose/write_test.go158
6 files changed, 854 insertions, 0 deletions
diff --git a/object/store/loose/helpers_test.go b/object/store/loose/helpers_test.go
new file mode 100644
index 00000000..22641049
--- /dev/null
+++ b/object/store/loose/helpers_test.go
@@ -0,0 +1,152 @@
+package loose_test
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/header"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/store/loose"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// gitOracleObject is one object created by git,
+// paired with its expected content body and full serialized form.
+type gitOracleObject struct {
+ name string
+ ty typ.Type
+ id id.ObjectID
+ body []byte
+ raw []byte
+}
+
+// openLooseStore opens a loose store over the repository's objects directory.
+func openLooseStore(t *testing.T, repo *testgit.Repo) *loose.Loose {
+ t.Helper()
+
+ repoRoot := repo.Root(t)
+
+ objectsRoot, err := repoRoot.OpenRoot(".git/objects")
+ if err != nil {
+ _ = repoRoot.Close()
+
+ t.Fatalf("OpenRoot(.git/objects): %v", err)
+ }
+
+ _ = repoRoot.Close()
+
+ t.Cleanup(func() { _ = objectsRoot.Close() })
+
+ looseStore, err := loose.New(objectsRoot, repo.ObjectFormat(t))
+ if err != nil {
+ t.Fatalf("loose.New: %v", err)
+ }
+
+ return looseStore
+}
+
+// gitOracleObjects builds one object of each kind with git
+// and precomputes each one's expected content body and full serialized form.
+func gitOracleObjects(t *testing.T, repo *testgit.Repo) []gitOracleObject {
+ t.Helper()
+
+ blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("blob body\n"))
+ if err != nil {
+ t.Fatalf("HashObject(blob): %v", err)
+ }
+
+ treeID, err := repo.MkTree(t, []testgit.TreeEntry{
+ {Mode: "100644", Type: typ.TypeBlob, OID: blobID, Name: "file"},
+ })
+ if err != nil {
+ t.Fatalf("MkTree: %v", err)
+ }
+
+ commitID, err := repo.CommitTree(t, treeID, testgit.CommitTreeOptions{Message: "subject\n\nbody"})
+ if err != nil {
+ t.Fatalf("CommitTree: %v", err)
+ }
+
+ tagID, err := repo.TagAnnotated(t, "v1", commitID, testgit.TagAnnotatedOptions{Message: "tag message"})
+ if err != nil {
+ t.Fatalf("TagAnnotated: %v", err)
+ }
+
+ kinds := []struct {
+ name string
+ ty typ.Type
+ id id.ObjectID
+ }{
+ {name: "blob", ty: typ.TypeBlob, id: blobID},
+ {name: "tree", ty: typ.TypeTree, id: treeID},
+ {name: "commit", ty: typ.TypeCommit, id: commitID},
+ {name: "tag", ty: typ.TypeTag, id: tagID},
+ }
+
+ objects := make([]gitOracleObject, 0, len(kinds))
+
+ for _, kind := range kinds {
+ body, err := repo.CatFile(t, kind.ty, kind.id)
+ if err != nil {
+ t.Fatalf("CatFile(%s): %v", kind.name, err)
+ }
+
+ raw := header.Append(nil, kind.ty, uint64(len(body)))
+ raw = append(raw, body...)
+
+ objects = append(objects, gitOracleObject{
+ name: kind.name,
+ ty: kind.ty,
+ id: kind.id,
+ body: body,
+ raw: raw,
+ })
+ }
+
+ return objects
+}
+
+// corruptLooseObjectTrailer flips the final byte of a loose object file,
+// damaging the zlib Adler-32 trailer.
+func corruptLooseObjectTrailer(t *testing.T, repo *testgit.Repo, objectID id.ObjectID) {
+ t.Helper()
+
+ root := repo.Root(t)
+
+ defer func() { _ = root.Close() }()
+
+ hex := objectID.String()
+ relPath := ".git/objects/" + hex[:2] + "/" + hex[2:]
+
+ file, err := root.OpenFile(relPath, os.O_RDWR, 0)
+ if err != nil {
+ t.Fatalf("OpenFile(%q): %v", relPath, err)
+ }
+
+ defer func() { _ = file.Close() }()
+
+ info, err := file.Stat()
+ if err != nil {
+ t.Fatalf("Stat(%q): %v", relPath, err)
+ }
+
+ if info.Size() == 0 {
+ t.Fatalf("corrupt trailer on empty file %q", relPath)
+ }
+
+ last := make([]byte, 1)
+
+ _, err = file.ReadAt(last, info.Size()-1)
+ if err != nil {
+ t.Fatalf("ReadAt(%q): %v", relPath, err)
+ }
+
+ last[0] ^= 0xff
+
+ _, err = file.WriteAt(last, info.Size()-1)
+ if err != nil {
+ t.Fatalf("WriteAt(%q): %v", relPath, err)
+ }
+}
diff --git a/object/store/loose/loose_test.go b/object/store/loose/loose_test.go
new file mode 100644
index 00000000..d55c87c5
--- /dev/null
+++ b/object/store/loose/loose_test.go
@@ -0,0 +1,26 @@
+package loose_test
+
+import (
+ "errors"
+ "os"
+ "testing"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/store/loose"
+)
+
+func TestNewRejectsUnknownObjectFormat(t *testing.T) {
+ t.Parallel()
+
+ root, err := os.OpenRoot(t.TempDir())
+ if err != nil {
+ t.Fatalf("OpenRoot: %v", err)
+ }
+
+ defer func() { _ = root.Close() }()
+
+ _, err = loose.New(root, id.ObjectFormatUnknown)
+ if !errors.Is(err, id.ErrInvalidObjectFormat) {
+ t.Fatalf("loose.New(unknown) = %v, want ErrInvalidObjectFormat", err)
+ }
+}
diff --git a/object/store/loose/quarantine_test.go b/object/store/loose/quarantine_test.go
new file mode 100644
index 00000000..2aab6566
--- /dev/null
+++ b/object/store/loose/quarantine_test.go
@@ -0,0 +1,116 @@
+package loose_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/store"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestQuarantinePromote(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ quarantine, err := looseStore.BeginObjectQuarantine(store.ObjectQuarantineOptions{})
+ if err != nil {
+ t.Fatalf("BeginObjectQuarantine: %v", err)
+ }
+
+ content := []byte("quarantined object\n")
+
+ objectID, err := quarantine.WriteBytesContent(typ.TypeBlob, content)
+ if err != nil {
+ t.Fatalf("quarantine.WriteBytesContent: %v", err)
+ }
+
+ ty, got, err := quarantine.ReadBytesContent(objectID)
+ if err != nil {
+ t.Fatalf("quarantine.ReadBytesContent: %v", err)
+ }
+
+ if ty != typ.TypeBlob {
+ t.Fatalf("quarantine type = %v, want %v", ty, typ.TypeBlob)
+ }
+
+ if !bytes.Equal(got, content) {
+ t.Fatalf("quarantine body mismatch")
+ }
+
+ _, _, err = looseStore.ReadBytesContent(objectID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("parent saw quarantined object before promote: %v", err)
+ }
+
+ err = quarantine.Promote()
+ if err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+
+ ty, got, err = looseStore.ReadBytesContent(objectID)
+ if err != nil {
+ t.Fatalf("parent ReadBytesContent after promote: %v", err)
+ }
+
+ if ty != typ.TypeBlob {
+ t.Fatalf("parent type = %v, want %v", ty, typ.TypeBlob)
+ }
+
+ if !bytes.Equal(got, content) {
+ t.Fatalf("parent body mismatch")
+ }
+ })
+ }
+}
+
+func TestQuarantineDiscard(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ quarantine, err := looseStore.BeginObjectQuarantine(store.ObjectQuarantineOptions{})
+ if err != nil {
+ t.Fatalf("BeginObjectQuarantine: %v", err)
+ }
+
+ content := []byte("discarded object\n")
+
+ objectID, err := quarantine.WriteBytesContent(typ.TypeBlob, content)
+ if err != nil {
+ t.Fatalf("quarantine.WriteBytesContent: %v", err)
+ }
+
+ err = quarantine.Discard()
+ if err != nil {
+ t.Fatalf("Discard: %v", err)
+ }
+
+ _, _, err = looseStore.ReadBytesContent(objectID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("parent saw discarded object: %v", err)
+ }
+ })
+ }
+}
diff --git a/object/store/loose/read_test.go b/object/store/loose/read_test.go
new file mode 100644
index 00000000..fe8b8e7c
--- /dev/null
+++ b/object/store/loose/read_test.go
@@ -0,0 +1,264 @@
+package loose_test
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/store"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestRead(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ objects := gitOracleObjects(t, repo)
+ looseStore := openLooseStore(t, repo)
+
+ t.Run("BytesFull", func(t *testing.T) {
+ t.Parallel()
+
+ for _, o := range objects {
+ got, err := looseStore.ReadBytesFull(o.id)
+ if err != nil {
+ t.Fatalf("%s: ReadBytesFull: %v", o.name, err)
+ }
+
+ if !bytes.Equal(got, o.raw) {
+ t.Fatalf("%s: ReadBytesFull mismatch", o.name)
+ }
+ }
+ })
+
+ t.Run("BytesContent", func(t *testing.T) {
+ t.Parallel()
+
+ for _, o := range objects {
+ gotType, gotBody, err := looseStore.ReadBytesContent(o.id)
+ if err != nil {
+ t.Fatalf("%s: ReadBytesContent: %v", o.name, err)
+ }
+
+ if gotType != o.ty {
+ t.Fatalf("%s: ReadBytesContent type = %v, want %v", o.name, gotType, o.ty)
+ }
+
+ if !bytes.Equal(gotBody, o.body) {
+ t.Fatalf("%s: ReadBytesContent body mismatch", o.name)
+ }
+ }
+ })
+
+ t.Run("Header", func(t *testing.T) {
+ t.Parallel()
+
+ for _, o := range objects {
+ gotType, gotSize, err := looseStore.ReadHeader(o.id)
+ if err != nil {
+ t.Fatalf("%s: ReadHeader: %v", o.name, err)
+ }
+
+ if gotType != o.ty {
+ t.Fatalf("%s: ReadHeader type = %v, want %v", o.name, gotType, o.ty)
+ }
+
+ if gotSize != uint64(len(o.body)) {
+ t.Fatalf("%s: ReadHeader size = %d, want %d", o.name, gotSize, len(o.body))
+ }
+ }
+ })
+
+ t.Run("ReaderFull", func(t *testing.T) {
+ t.Parallel()
+
+ for _, o := range objects {
+ reader, err := looseStore.ReadReaderFull(o.id)
+ if err != nil {
+ t.Fatalf("%s: ReadReaderFull: %v", o.name, err)
+ }
+
+ got, err := io.ReadAll(reader)
+ if err != nil {
+ _ = reader.Close()
+
+ t.Fatalf("%s: ReadReaderFull ReadAll: %v", o.name, err)
+ }
+
+ err = reader.Close()
+ if err != nil {
+ t.Fatalf("%s: ReadReaderFull Close: %v", o.name, err)
+ }
+
+ if !bytes.Equal(got, o.raw) {
+ t.Fatalf("%s: ReadReaderFull mismatch", o.name)
+ }
+ }
+ })
+
+ t.Run("ReaderContent", func(t *testing.T) {
+ t.Parallel()
+
+ for _, o := range objects {
+ gotType, gotSize, reader, err := looseStore.ReadReaderContent(o.id)
+ if err != nil {
+ t.Fatalf("%s: ReadReaderContent: %v", o.name, err)
+ }
+
+ got, err := io.ReadAll(reader)
+ if err != nil {
+ _ = reader.Close()
+
+ t.Fatalf("%s: ReadReaderContent ReadAll: %v", o.name, err)
+ }
+
+ err = reader.Close()
+ if err != nil {
+ t.Fatalf("%s: ReadReaderContent Close: %v", o.name, err)
+ }
+
+ if gotType != o.ty {
+ t.Fatalf("%s: ReadReaderContent type = %v, want %v", o.name, gotType, o.ty)
+ }
+
+ if gotSize != uint64(len(o.body)) {
+ t.Fatalf("%s: ReadReaderContent size = %d, want %d", o.name, gotSize, len(o.body))
+ }
+
+ if !bytes.Equal(got, o.body) {
+ t.Fatalf("%s: ReadReaderContent mismatch", o.name)
+ }
+ }
+ })
+ })
+ }
+}
+
+func TestReadNotFound(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ missingID, err := objectFormat.FromString(strings.Repeat("0", objectFormat.HexLen()))
+ if err != nil {
+ t.Fatalf("FromString(missing): %v", err)
+ }
+
+ _, err = looseStore.ReadBytesFull(missingID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("ReadBytesFull not-found = %v", err)
+ }
+
+ _, _, err = looseStore.ReadBytesContent(missingID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("ReadBytesContent not-found = %v", err)
+ }
+
+ _, _, err = looseStore.ReadHeader(missingID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("ReadHeader not-found = %v", err)
+ }
+
+ _, err = looseStore.ReadReaderFull(missingID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("ReadReaderFull not-found = %v", err)
+ }
+
+ _, _, _, err = looseStore.ReadReaderContent(missingID)
+ if !errors.Is(err, store.ErrObjectNotFound) {
+ t.Fatalf("ReadReaderContent not-found = %v", err)
+ }
+
+ otherFormat := objectFormat
+
+ for _, candidate := range id.SupportedObjectFormats() {
+ if candidate != objectFormat {
+ otherFormat = candidate
+
+ break
+ }
+ }
+
+ if otherFormat == objectFormat {
+ return
+ }
+
+ mismatchID, err := otherFormat.FromString(strings.Repeat("1", otherFormat.HexLen()))
+ if err != nil {
+ t.Fatalf("FromString(mismatch): %v", err)
+ }
+
+ _, err = looseStore.ReadBytesFull(mismatchID)
+ if !errors.Is(err, id.ErrInvalidObjectFormat) {
+ t.Fatalf("ReadBytesFull format mismatch = %v, want ErrInvalidObjectFormat", err)
+ }
+ })
+ }
+}
+
+func TestReadCorruptTrailer(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ content := []byte("corrupt-trailer-check\n")
+
+ objectID, err := looseStore.WriteBytesContent(typ.TypeBlob, content)
+ if err != nil {
+ t.Fatalf("WriteBytesContent: %v", err)
+ }
+
+ corruptLooseObjectTrailer(t, repo, objectID)
+
+ // Stops before the trailer.
+ ty, size, err := looseStore.ReadHeader(objectID)
+ if err != nil {
+ t.Fatalf("ReadHeader: %v", err)
+ }
+
+ if ty != typ.TypeBlob {
+ t.Fatalf("ReadHeader type = %v, want %v", ty, typ.TypeBlob)
+ }
+
+ if size != uint64(len(content)) {
+ t.Fatalf("ReadHeader size = %d, want %d", size, len(content))
+ }
+
+ // Consumes the whole stream.
+ _, err = looseStore.ReadBytesFull(objectID)
+ if err == nil {
+ t.Fatalf("ReadBytesFull on corrupt trailer succeeded")
+ }
+ })
+ }
+}
diff --git a/object/store/loose/roundtrip_test.go b/object/store/loose/roundtrip_test.go
new file mode 100644
index 00000000..e99da0f2
--- /dev/null
+++ b/object/store/loose/roundtrip_test.go
@@ -0,0 +1,138 @@
+package loose_test
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/header"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ ty typ.Type
+ content []byte
+ }{
+ {name: "blob", ty: typ.TypeBlob, content: []byte("roundtrip blob\n")},
+ {name: "empty blob", ty: typ.TypeBlob, content: []byte{}},
+ {name: "tree", ty: typ.TypeTree, content: []byte("roundtrip tree bytes")},
+ {name: "commit", ty: typ.TypeCommit, content: []byte("roundtrip commit bytes")},
+ {name: "tag", ty: typ.TypeTag, content: []byte("roundtrip tag bytes")},
+ }
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ wantRaw := header.Append(nil, tc.ty, uint64(len(tc.content)))
+ wantRaw = append(wantRaw, tc.content...)
+
+ objectID, err := looseStore.WriteBytesContent(tc.ty, tc.content)
+ if err != nil {
+ t.Fatalf("WriteBytesContent: %v", err)
+ }
+
+ gotRaw, err := looseStore.ReadBytesFull(objectID)
+ if err != nil {
+ t.Fatalf("ReadBytesFull: %v", err)
+ }
+
+ if !bytes.Equal(gotRaw, wantRaw) {
+ t.Fatalf("ReadBytesFull mismatch")
+ }
+
+ gotType, gotBody, err := looseStore.ReadBytesContent(objectID)
+ if err != nil {
+ t.Fatalf("ReadBytesContent: %v", err)
+ }
+
+ if gotType != tc.ty {
+ t.Fatalf("ReadBytesContent type = %v, want %v", gotType, tc.ty)
+ }
+
+ if !bytes.Equal(gotBody, tc.content) {
+ t.Fatalf("ReadBytesContent body mismatch")
+ }
+
+ headType, headSize, err := looseStore.ReadHeader(objectID)
+ if err != nil {
+ t.Fatalf("ReadHeader: %v", err)
+ }
+
+ if headType != tc.ty {
+ t.Fatalf("ReadHeader type = %v, want %v", headType, tc.ty)
+ }
+
+ if headSize != uint64(len(tc.content)) {
+ t.Fatalf("ReadHeader size = %d, want %d", headSize, len(tc.content))
+ }
+
+ fullReader, err := looseStore.ReadReaderFull(objectID)
+ if err != nil {
+ t.Fatalf("ReadReaderFull: %v", err)
+ }
+
+ gotFull, err := io.ReadAll(fullReader)
+ if err != nil {
+ _ = fullReader.Close()
+
+ t.Fatalf("ReadReaderFull ReadAll: %v", err)
+ }
+
+ err = fullReader.Close()
+ if err != nil {
+ t.Fatalf("ReadReaderFull Close: %v", err)
+ }
+
+ if !bytes.Equal(gotFull, wantRaw) {
+ t.Fatalf("ReadReaderFull mismatch")
+ }
+
+ contentType, contentSize, contentReader, err := looseStore.ReadReaderContent(objectID)
+ if err != nil {
+ t.Fatalf("ReadReaderContent: %v", err)
+ }
+
+ gotContent, err := io.ReadAll(contentReader)
+ if err != nil {
+ _ = contentReader.Close()
+
+ t.Fatalf("ReadReaderContent ReadAll: %v", err)
+ }
+
+ err = contentReader.Close()
+ if err != nil {
+ t.Fatalf("ReadReaderContent Close: %v", err)
+ }
+
+ if contentType != tc.ty {
+ t.Fatalf("ReadReaderContent type = %v, want %v", contentType, tc.ty)
+ }
+
+ if contentSize != uint64(len(tc.content)) {
+ t.Fatalf("ReadReaderContent size = %d, want %d", contentSize, len(tc.content))
+ }
+
+ if !bytes.Equal(gotContent, tc.content) {
+ t.Fatalf("ReadReaderContent mismatch")
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/object/store/loose/write_test.go b/object/store/loose/write_test.go
new file mode 100644
index 00000000..631b5fbc
--- /dev/null
+++ b/object/store/loose/write_test.go
@@ -0,0 +1,158 @@
+package loose_test
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/header"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/store"
+ "lindenii.org/go/furgit/object/store/loose"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestWrite(t *testing.T) {
+ t.Parallel()
+
+ writes := []struct {
+ name string
+ write func(looseStore *loose.Loose, content []byte) (id.ObjectID, error)
+ }{
+ {
+ name: "BytesContent",
+ write: func(looseStore *loose.Loose, content []byte) (id.ObjectID, error) {
+ return looseStore.WriteBytesContent(typ.TypeBlob, content)
+ },
+ },
+ {
+ name: "ReaderContent",
+ write: func(looseStore *loose.Loose, content []byte) (id.ObjectID, error) {
+ return looseStore.WriteReaderContent(typ.TypeBlob, uint64(len(content)), bytes.NewReader(content))
+ },
+ },
+ {
+ name: "BytesFull",
+ write: func(looseStore *loose.Loose, content []byte) (id.ObjectID, error) {
+ raw := header.Append(nil, typ.TypeBlob, uint64(len(content)))
+ raw = append(raw, content...)
+
+ return looseStore.WriteBytesFull(raw)
+ },
+ },
+ {
+ name: "ReaderFull",
+ write: func(looseStore *loose.Loose, content []byte) (id.ObjectID, error) {
+ raw := header.Append(nil, typ.TypeBlob, uint64(len(content)))
+ raw = append(raw, content...)
+
+ return looseStore.WriteReaderFull(bytes.NewReader(raw))
+ },
+ },
+ }
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ for _, w := range writes {
+ t.Run(w.name, func(t *testing.T) {
+ content := []byte("written via " + w.name + "\n")
+
+ want, err := repo.HashObject(t, typ.TypeBlob, bytes.NewReader(content))
+ if err != nil {
+ t.Fatalf("HashObject: %v", err)
+ }
+
+ got, err := w.write(looseStore, content)
+ if err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ if got != want {
+ t.Fatalf("id = %s, want %s", got, want)
+ }
+
+ gotBody, err := repo.CatFile(t, typ.TypeBlob, got)
+ if err != nil {
+ t.Fatalf("CatFile: %v", err)
+ }
+
+ if !bytes.Equal(gotBody, content) {
+ t.Fatalf("git cat-file body mismatch")
+ }
+
+ regot, err := w.write(looseStore, content)
+ if err != nil {
+ t.Fatalf("rewrite: %v", err)
+ }
+
+ if regot != want {
+ t.Fatalf("rewrite id = %s, want %s", regot, want)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestWriteRejects(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ looseStore := openLooseStore(t, repo)
+
+ t.Run("ContentOverflow", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := looseStore.WriteReaderContent(typ.TypeBlob, 1, bytes.NewReader([]byte("hello")))
+ if !errors.Is(err, store.ErrInvalidObject) {
+ t.Fatalf("err = %v, want ErrInvalidObject", err)
+ }
+ })
+
+ t.Run("ContentShort", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := looseStore.WriteReaderContent(typ.TypeBlob, 5, bytes.NewReader([]byte("x")))
+ if !errors.Is(err, store.ErrInvalidObject) {
+ t.Fatalf("err = %v, want ErrInvalidObject", err)
+ }
+ })
+
+ t.Run("FullMalformedHeader", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := looseStore.WriteReaderFull(bytes.NewReader([]byte("not-a-header")))
+ if !errors.Is(err, store.ErrInvalidObject) {
+ t.Fatalf("err = %v, want ErrInvalidObject", err)
+ }
+ })
+
+ t.Run("FullSizeMismatch", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := looseStore.WriteReaderFull(bytes.NewReader([]byte("blob 1\x00hello")))
+ if !errors.Is(err, store.ErrInvalidObject) {
+ t.Fatalf("err = %v, want ErrInvalidObject", err)
+ }
+ })
+ })
+ }
+}