diff options
| author | 2026-03-30 17:34:16 +0000 | |
|---|---|---|
| committer | 2026-03-30 17:34:16 +0000 | |
| commit | bf394989bad0242c6596520b7528c1a06563efa7 (patch) | |
| tree | 09d32b603aec4450ac6eb220e561f00eb4fb8472 /object/store | |
| parent | object/store/loose: Fix lack of tmp file removal (diff) | |
| signature | No signature | |
object/store/loose: Add quarantine
Diffstat (limited to 'object/store')
| -rw-r--r-- | object/store/loose/quarantine.go | 19 | ||||
| -rw-r--r-- | object/store/loose/quarantine_begin.go | 63 | ||||
| -rw-r--r-- | object/store/loose/quarantine_discard.go | 18 | ||||
| -rw-r--r-- | object/store/loose/quarantine_promote.go | 116 | ||||
| -rw-r--r-- | object/store/loose/quarantine_test.go | 119 |
5 files changed, 335 insertions, 0 deletions
diff --git a/object/store/loose/quarantine.go b/object/store/loose/quarantine.go new file mode 100644 index 00000000..52fb8120 --- /dev/null +++ b/object/store/loose/quarantine.go @@ -0,0 +1,19 @@ +package loose + +import ( + "os" + + objectstore "codeberg.org/lindenii/furgit/object/store" +) + +var _ objectstore.ObjectQuarantiner = (*Store)(nil) + +type objectQuarantine struct { + *Store + + parent *Store + tempName string + tempRoot *os.Root +} + +var _ objectstore.ObjectQuarantine = (*objectQuarantine)(nil) diff --git a/object/store/loose/quarantine_begin.go b/object/store/loose/quarantine_begin.go new file mode 100644 index 00000000..dd27f968 --- /dev/null +++ b/object/store/loose/quarantine_begin.go @@ -0,0 +1,63 @@ +package loose + +import ( + "crypto/rand" + "errors" + "fmt" + "io/fs" + "os" + + objectstore "codeberg.org/lindenii/furgit/object/store" +) + +// BeginObjectQuarantine creates one quarantined loose store rooted privately +// beneath the destination loose root. +// +// Labels: Deps-Borrowed, Life-Parent, Close-No. +func (store *Store) BeginObjectQuarantine(_ objectstore.ObjectQuarantineOptions) (objectstore.ObjectQuarantine, error) { + tempName, tempRoot, err := createLooseQuarantineRoot(store.root) + if err != nil { + return nil, err + } + + quarantineStore, err := New(tempRoot, store.algo) + if err != nil { + _ = tempRoot.Close() + _ = store.root.RemoveAll(tempName) + + return nil, err + } + + return &objectQuarantine{ + Store: quarantineStore, + parent: store, + tempName: tempName, + tempRoot: tempRoot, + }, nil +} + +func createLooseQuarantineRoot(parent *os.Root) (string, *os.Root, error) { + for range 32 { + name := "tmp_looseq_" + rand.Text() + + err := parent.Mkdir(name, 0o700) + if err == nil { + root, err := parent.OpenRoot(name) + if err == nil { + return name, root, nil + } + + _ = parent.RemoveAll(name) + + return "", nil, err + } + + if errors.Is(err, fs.ErrExist) { + continue + } + + return "", nil, err + } + + return "", nil, fmt.Errorf("objectstore/loose: unable to create quarantine directory") +} diff --git a/object/store/loose/quarantine_discard.go b/object/store/loose/quarantine_discard.go new file mode 100644 index 00000000..3e783d0e --- /dev/null +++ b/object/store/loose/quarantine_discard.go @@ -0,0 +1,18 @@ +package loose + +// Discard removes the quarantine and invalidates the receiver. +func (quarantine *objectQuarantine) Discard() error { + closeErr := quarantine.Close() + tempRootErr := quarantine.tempRoot.Close() + removeErr := quarantine.parent.root.RemoveAll(quarantine.tempName) + + if closeErr != nil { + return closeErr + } + + if tempRootErr != nil { + return tempRootErr + } + + return removeErr +} diff --git a/object/store/loose/quarantine_promote.go b/object/store/loose/quarantine_promote.go new file mode 100644 index 00000000..79759eb9 --- /dev/null +++ b/object/store/loose/quarantine_promote.go @@ -0,0 +1,116 @@ +package loose + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// Promote publishes all quarantined loose objects into the parent loose store +// and invalidates the receiver. +func (quarantine *objectQuarantine) Promote() error { + closeErr := quarantine.Close() + promoteErr := promoteLooseQuarantine(quarantine.parent, quarantine.tempName, quarantine.tempRoot) + tempRootErr := quarantine.tempRoot.Close() + removeErr := quarantine.parent.root.RemoveAll(quarantine.tempName) + + if closeErr != nil { + return closeErr + } + + if promoteErr != nil { + return promoteErr + } + + if tempRootErr != nil { + return tempRootErr + } + + return removeErr +} + +func promoteLooseQuarantine(parent *Store, tempName string, tempRoot *os.Root) error { + entries, err := fs.ReadDir(tempRoot.FS(), ".") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + + for _, entry := range entries { + if !entry.IsDir() { + return fmt.Errorf("objectstore/loose: quarantine contains unexpected file %q", entry.Name()) + } + + if len(entry.Name()) == 2 && isHexString(entry.Name()) { + return fmt.Errorf("objectstore/loose: quarantine contains invalid shard %q", entry.Name()) + } + + err := promoteLooseQuarantineShard(parent, tempName, tempRoot, entry.Name()) + if err != nil { + return err + } + } + + return nil +} + +func promoteLooseQuarantineShard(parent *Store, tempName string, tempRoot *os.Root, shard string) error { + entries, err := fs.ReadDir(tempRoot.FS(), shard) + if err != nil { + return err + } + + err = parent.root.MkdirAll(shard, 0o755) + if err != nil { + return err + } + + wantNameLen := parent.algo.HexLen() - 2 + + for _, entry := range entries { + if entry.IsDir() { + return fmt.Errorf("objectstore/loose: quarantine shard %q contains unexpected directory %q", shard, entry.Name()) + } + + if len(entry.Name()) != wantNameLen || !isHexString(entry.Name()) { + return fmt.Errorf("objectstore/loose: quarantine shard %q contains invalid object path %q", shard, entry.Name()) + } + + err := promoteLooseQuarantineObject(parent.root, filepath.Join(tempName, shard, entry.Name()), filepath.Join(shard, entry.Name())) + if err != nil { + return err + } + } + + return nil +} + +func promoteLooseQuarantineObject(root *os.Root, src, dst string) error { + err := root.Link(src, dst) + if err == nil { + _ = root.Remove(src) + + return nil + } + + if errors.Is(err, fs.ErrExist) { + _ = root.Remove(src) + + return nil + } + + return fmt.Errorf("objectstore/loose: promote quarantine %q -> %q: %w", src, dst, err) +} + +func isHexString(s string) bool { + for _, ch := range s { + if ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'f') || ('A' <= ch && ch <= 'F') { + continue + } + + return false + } + + return true +} diff --git a/object/store/loose/quarantine_test.go b/object/store/loose/quarantine_test.go new file mode 100644 index 00000000..4fd1b8f9 --- /dev/null +++ b/object/store/loose/quarantine_test.go @@ -0,0 +1,119 @@ +package loose_test + +import ( + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + objectid "codeberg.org/lindenii/furgit/object/id" + objectstore "codeberg.org/lindenii/furgit/object/store" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +func TestLooseQuarantinePromotePublishesWrittenObjects(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + store := openLooseStore(t, testRepo, algo) + + quarantiner, ok := any(store).(objectstore.ObjectQuarantiner) + if !ok { + t.Fatal("loose store does not implement ObjectQuarantiner") + } + + quarantine, err := quarantiner.BeginObjectQuarantine(objectstore.ObjectQuarantineOptions{}) + if err != nil { + t.Fatalf("BeginObjectQuarantine: %v", err) + } + + content := []byte("quarantined loose object\n") + + id, err := quarantine.WriteBytesContent(objecttype.TypeBlob, content) + if err != nil { + t.Fatalf("quarantine.WriteBytesContent: %v", err) + } + + ty, got, err := quarantine.ReadBytesContent(id) + if err != nil { + t.Fatalf("quarantine.ReadBytesContent: %v", err) + } + + if ty != objecttype.TypeBlob { + t.Fatalf("quarantine.ReadBytesContent type = %v, want %v", ty, objecttype.TypeBlob) + } + + if !bytes.Equal(got, content) { + t.Fatal("quarantine.ReadBytesContent mismatch") + } + + _, _, err = store.ReadBytesContent(id) + if err == nil { + t.Fatal("store.ReadBytesContent unexpectedly saw quarantined object before promote") + } + + err = quarantine.Promote() + if err != nil { + t.Fatalf("quarantine.Promote: %v", err) + } + + err = store.Refresh() + if err != nil { + t.Fatalf("store.Refresh: %v", err) + } + + ty, got, err = store.ReadBytesContent(id) + if err != nil { + t.Fatalf("store.ReadBytesContent after promote: %v", err) + } + + if ty != objecttype.TypeBlob { + t.Fatalf("store.ReadBytesContent type = %v, want %v", ty, objecttype.TypeBlob) + } + + if !bytes.Equal(got, content) { + t.Fatal("store.ReadBytesContent mismatch") + } + }) +} + +func TestLooseQuarantineDiscardDropsWrittenObjects(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + store := openLooseStore(t, testRepo, algo) + + quarantiner, ok := any(store).(objectstore.ObjectQuarantiner) + if !ok { + t.Fatal("expected objectstore.ObjectQuarantiner") + } + + quarantine, err := quarantiner.BeginObjectQuarantine(objectstore.ObjectQuarantineOptions{}) + if err != nil { + t.Fatalf("BeginObjectQuarantine: %v", err) + } + + content := []byte("discarded loose object\n") + + id, err := quarantine.WriteBytesContent(objecttype.TypeBlob, content) + if err != nil { + t.Fatalf("quarantine.WriteBytesContent: %v", err) + } + + err = quarantine.Discard() + if err != nil { + t.Fatalf("quarantine.Discard: %v", err) + } + + err = store.Refresh() + if err != nil { + t.Fatalf("store.Refresh: %v", err) + } + + _, _, err = store.ReadBytesContent(id) + if err == nil { + t.Fatal("store.ReadBytesContent unexpectedly saw discarded object") + } + }) +} |
