diff options
Diffstat (limited to 'objectstore')
| -rw-r--r-- | objectstore/loose/write_bytes.go | 123 | ||||
| -rw-r--r-- | objectstore/loose/write_test.go | 93 |
2 files changed, 216 insertions, 0 deletions
diff --git a/objectstore/loose/write_bytes.go b/objectstore/loose/write_bytes.go new file mode 100644 index 00000000..fe2bafb9 --- /dev/null +++ b/objectstore/loose/write_bytes.go @@ -0,0 +1,123 @@ +package loose + +import ( + "compress/zlib" + "crypto/rand" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "codeberg.org/lindenii/furgit/objectheader" + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objecttype" +) + +const tempObjectFilePrefix = "tmp_obj_" + +// WriteBytesFull writes a full serialized object as "type size\\x00content". +func (store *Store) WriteBytesFull(raw []byte) (objectid.ObjectID, error) { + var zero objectid.ObjectID + + if _, _, err := parseRaw(raw); err != nil { + return zero, err + } + + id := store.algo.Sum(raw) + relPath, err := store.objectPath(id) + if err != nil { + return zero, err + } + if err := store.writeCompressedAtomic(relPath, raw); err != nil { + return zero, err + } + return id, nil +} + +// WriteBytesContent writes typed content bytes as a loose object. +func (store *Store) WriteBytesContent(ty objecttype.Type, content []byte) (objectid.ObjectID, error) { + var zero objectid.ObjectID + + header, ok := objectheader.Encode(ty, int64(len(content))) + if !ok { + return zero, fmt.Errorf("objectstore/loose: failed to encode object header for type %d", ty) + } + + raw := make([]byte, len(header)+len(content)) + copy(raw, header) + copy(raw[len(header):], content) + return store.WriteBytesFull(raw) +} + +// writeCompressedAtomic compresses raw and writes it to relPath atomically. +func (store *Store) writeCompressedAtomic(relPath string, raw []byte) error { + if _, err := store.root.Stat(relPath); err == nil { + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + + dir := filepath.Dir(relPath) + if err := store.root.MkdirAll(dir, 0o755); err != nil { + return err + } + + tmpRelPath, tmpFile, err := store.createTempObjectFile(dir) + if err != nil { + return err + } + + cleanup := true + defer func() { + if tmpFile != nil { + _ = tmpFile.Close() + } + if cleanup { + _ = store.root.Remove(tmpRelPath) + } + }() + + zw := zlib.NewWriter(tmpFile) + if _, err := zw.Write(raw); err != nil { + _ = zw.Close() + return err + } + if err := zw.Close(); err != nil { + return err + } + if err := tmpFile.Sync(); err != nil { + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + tmpFile = nil + + if err := store.root.Rename(tmpRelPath, relPath); err != nil { + if errors.Is(err, fs.ErrExist) { + return nil + } + return err + } + + cleanup = false + return nil +} + +// createTempObjectFile creates a unique temporary object file within dir. +func (store *Store) createTempObjectFile(dir string) (string, *os.File, error) { + for range 16 { + relPath := filepath.Join(dir, tempObjectFilePrefix+rand.Text()) + file, err := store.root.OpenFile(relPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + return relPath, file, nil + } + if errors.Is(err, fs.ErrExist) { + continue + } + return "", nil, err + } + + return "", nil, errors.New("objectstore/loose: failed to create temporary object file") +} diff --git a/objectstore/loose/write_test.go b/objectstore/loose/write_test.go new file mode 100644 index 00000000..835d451f --- /dev/null +++ b/objectstore/loose/write_test.go @@ -0,0 +1,93 @@ +package loose_test + +import ( + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/objectheader" + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objecttype" +) + +func TestLooseStoreWriteBytesContentAgainstGit(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + testRepo := testgit.NewBareRepo(t, algo) + store := openLooseStore(t, testRepo.Dir(), algo) + + content := []byte("written-by-loose-store\n") + expectedHex := testRepo.RunInput(t, content, "hash-object", "-t", "blob", "--stdin") + expectedID, err := objectid.ParseHex(algo, expectedHex) + if err != nil { + t.Fatalf("ParseHex(expected): %v", err) + } + + writtenID, err := store.WriteBytesContent(objecttype.TypeBlob, content) + if err != nil { + t.Fatalf("WriteBytesContent: %v", err) + } + if writtenID != expectedID { + t.Fatalf("WriteBytesContent id = %s, want %s", writtenID, expectedID) + } + + gotBody := testRepo.CatFile(t, "blob", writtenID) + if !bytes.Equal(gotBody, content) { + t.Fatalf("git cat-file body mismatch") + } + + writtenID2, err := store.WriteBytesContent(objecttype.TypeBlob, content) + if err != nil { + t.Fatalf("WriteBytesContent second write: %v", err) + } + if writtenID2 != expectedID { + t.Fatalf("WriteBytesContent second id = %s, want %s", writtenID2, expectedID) + } + }) +} + +func TestLooseStoreWriteBytesFullAgainstGit(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + testRepo := testgit.NewBareRepo(t, algo) + store := openLooseStore(t, testRepo.Dir(), algo) + + body := []byte("full-write-body\n") + header, ok := objectheader.Encode(objecttype.TypeBlob, int64(len(body))) + if !ok { + t.Fatalf("objectheader.Encode failed") + } + raw := make([]byte, len(header)+len(body)) + copy(raw, header) + copy(raw[len(header):], body) + + wantID := algo.Sum(raw) + gotID, err := store.WriteBytesFull(raw) + if err != nil { + t.Fatalf("WriteBytesFull: %v", err) + } + if gotID != wantID { + t.Fatalf("WriteBytesFull id = %s, want %s", gotID, wantID) + } + + gotBody := testRepo.CatFile(t, "blob", gotID) + if !bytes.Equal(gotBody, body) { + t.Fatalf("git cat-file body mismatch") + } + }) +} + +func TestLooseStoreWriteValidationErrors(t *testing.T) { + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + testRepo := testgit.NewBareRepo(t, algo) + store := openLooseStore(t, testRepo.Dir(), algo) + + if _, err := store.WriteBytesFull([]byte("blob 1\x00hello")); err == nil { + t.Fatalf("WriteBytesFull expected size/content mismatch error") + } + if _, err := store.WriteBytesFull([]byte("not-a-header")); err == nil { + t.Fatalf("WriteBytesFull expected malformed header error") + } + if _, err := store.WriteBytesContent(objecttype.TypeInvalid, []byte("x")); err == nil { + t.Fatalf("WriteBytesContent expected invalid type error") + } + }) +} |
