diff options
| author | 2026-03-30 15:53:50 +0000 | |
|---|---|---|
| committer | 2026-03-30 15:53:50 +0000 | |
| commit | 1d2912f952ccc11f8b6346aa8160efd1e58acfe0 (patch) | |
| tree | 3ce48c505428547614703a076d14ee3961765664 /object/store/packed | |
| parent | object/store/packed/internal/ingest: And of course I forgot to goimports (diff) | |
| signature | No signature | |
object/store/packed: Add quarantine
Diffstat (limited to 'object/store/packed')
| -rw-r--r-- | object/store/packed/quarantine.go | 19 | ||||
| -rw-r--r-- | object/store/packed/quarantine_begin.go | 63 | ||||
| -rw-r--r-- | object/store/packed/quarantine_discard.go | 18 | ||||
| -rw-r--r-- | object/store/packed/quarantine_promote.go | 89 | ||||
| -rw-r--r-- | object/store/packed/quarantine_test.go | 215 |
5 files changed, 404 insertions, 0 deletions
diff --git a/object/store/packed/quarantine.go b/object/store/packed/quarantine.go new file mode 100644 index 00000000..a8f6d08c --- /dev/null +++ b/object/store/packed/quarantine.go @@ -0,0 +1,19 @@ +package packed + +import ( + "os" + + objectstore "codeberg.org/lindenii/furgit/object/store" +) + +var _ objectstore.PackQuarantiner = (*Store)(nil) + +type packQuarantine struct { + *Store + + parent *Store + tempName string + tempRoot *os.Root +} + +var _ objectstore.PackQuarantine = (*packQuarantine)(nil) diff --git a/object/store/packed/quarantine_begin.go b/object/store/packed/quarantine_begin.go new file mode 100644 index 00000000..06b9a8a6 --- /dev/null +++ b/object/store/packed/quarantine_begin.go @@ -0,0 +1,63 @@ +package packed + +import ( + "crypto/rand" + "errors" + "fmt" + "io/fs" + "os" + + objectstore "codeberg.org/lindenii/furgit/object/store" +) + +// BeginPackQuarantine creates one quarantined packed store rooted privately +// beneath the destination pack root. +// +// Labels: Deps-Borrowed, Life-Parent, Close-No. +func (store *Store) BeginPackQuarantine(_ objectstore.PackQuarantineOptions) (objectstore.PackQuarantine, error) { + tempName, tempRoot, err := createPackQuarantineRoot(store.root) + if err != nil { + return nil, err + } + + quarantineStore, err := New(tempRoot, store.algo, store.opts) + if err != nil { + _ = tempRoot.Close() + _ = store.root.RemoveAll(tempName) + + return nil, err + } + + return &packQuarantine{ + Store: quarantineStore, + parent: store, + tempName: tempName, + tempRoot: tempRoot, + }, nil +} + +func createPackQuarantineRoot(parent *os.Root) (string, *os.Root, error) { + for range 32 { + name := "tmp_packq_" + 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("packed: unable to create quarantine directory") +} diff --git a/object/store/packed/quarantine_discard.go b/object/store/packed/quarantine_discard.go new file mode 100644 index 00000000..a1dc7310 --- /dev/null +++ b/object/store/packed/quarantine_discard.go @@ -0,0 +1,18 @@ +package packed + +// Discard removes the quarantine and invalidates the receiver. +func (quarantine *packQuarantine) 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/packed/quarantine_promote.go b/object/store/packed/quarantine_promote.go new file mode 100644 index 00000000..a4eb426d --- /dev/null +++ b/object/store/packed/quarantine_promote.go @@ -0,0 +1,89 @@ +package packed + +import ( + "errors" + "fmt" + "io/fs" + "os" + "slices" + "strings" +) + +// Promote publishes all finalized pack artifacts in the quarantine into the +// parent packed store and invalidates the receiver. +func (quarantine *packQuarantine) Promote() error { + closeErr := quarantine.Close() + promoteErr := promotePackQuarantine(quarantine.parent.root, quarantine.tempName, quarantine.tempRoot) + tempRootErr := quarantine.tempRoot.Close() + removeErr := quarantine.parent.root.RemoveAll(quarantine.tempName) + + if closeErr != nil { + return closeErr + } + + if tempRootErr != nil { + return tempRootErr + } + + if promoteErr != nil { + return promoteErr + } + + return removeErr +} + +func promotePackQuarantine(parent *os.Root, tempName string, tempRoot *os.Root) error { + entries, err := fs.ReadDir(tempRoot.FS(), ".") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + + slices.SortFunc(entries, func(left, right fs.DirEntry) int { + return packPromotionPriority(left.Name()) - packPromotionPriority(right.Name()) + }) + + for _, entry := range entries { + if entry.IsDir() { + return fmt.Errorf("packed: quarantine contains unexpected directory %q", entry.Name()) + } + + err := promotePackQuarantineFile(parent, tempName, entry.Name()) + if err != nil { + return err + } + } + + return nil +} + +func promotePackQuarantineFile(parent *os.Root, tempName, name string) error { + src := tempName + "/" + name + + err := parent.Link(src, name) + if err == nil { + _ = parent.Remove(src) + + return nil + } + + if errors.Is(err, fs.ErrExist) { + _ = parent.Remove(src) + + return nil + } + + return fmt.Errorf("packed: promote quarantine %q -> %q: %w", src, name, err) +} + +func packPromotionPriority(name string) int { + switch { + case strings.HasPrefix(name, "pack-") && strings.HasSuffix(name, ".pack"): + return 1 + case strings.HasPrefix(name, "pack-") && strings.HasSuffix(name, ".rev"): + return 2 + case strings.HasPrefix(name, "pack-") && strings.HasSuffix(name, ".idx"): + return 3 + default: + return 0 + } +} diff --git a/object/store/packed/quarantine_test.go b/object/store/packed/quarantine_test.go new file mode 100644 index 00000000..036da535 --- /dev/null +++ b/object/store/packed/quarantine_test.go @@ -0,0 +1,215 @@ +package packed_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + objectid "codeberg.org/lindenii/furgit/object/id" + objectstore "codeberg.org/lindenii/furgit/object/store" + "codeberg.org/lindenii/furgit/object/store/packed" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +func fixturePath(t *testing.T, algo objectid.Algorithm, name string) string { + t.Helper() + + return filepath.Join("internal", "ingest", "testdata", "fixtures", algo.String(), name) +} + +func fixtureBytes(t *testing.T, algo objectid.Algorithm, name string) []byte { + t.Helper() + + path := fixturePath(t, algo, name) + dir := filepath.Dir(path) + base := filepath.Base(path) + + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatalf("open fixture root %q: %v", dir, err) + } + + defer func() { + err := root.Close() + if err != nil { + t.Fatalf("close fixture root %q: %v", dir, err) + } + }() + + data, err := root.ReadFile(base) + if err != nil { + t.Fatalf("read fixture %q: %v", base, err) + } + + return data +} + +func fixtureMetadata(t *testing.T, algo objectid.Algorithm) map[string]string { + t.Helper() + + data := fixtureBytes(t, algo, "METADATA.txt") + out := make(map[string]string) + + for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + t.Fatalf("invalid fixture metadata line %q", line) + } + + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + + return out +} + +func fixtureOID(t *testing.T, algo objectid.Algorithm, key string) objectid.ObjectID { + t.Helper() + + meta := fixtureMetadata(t, algo) + + hex, ok := meta[key] + if !ok { + t.Fatalf("missing fixture metadata key %q", key) + } + + id, err := objectid.ParseHex(algo, hex) + if err != nil { + t.Fatalf("parse fixture metadata oid %q: %v", hex, err) + } + + return id +} + +func TestPackQuarantinePromotePublishesWrittenObjects(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + head := fixtureOID(t, algo, "head") + packBytes := fixtureBytes(t, algo, "nonthin.pack") + + repo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + packRoot := repo.OpenPackRoot(t) + + store, err := packed.New(packRoot, algo, packed.Options{WriteRev: true}) + if err != nil { + t.Fatalf("packed.New: %v", err) + } + + defer func() { + err := store.Close() + if err != nil { + t.Fatalf("store.Close: %v", err) + } + }() + + quarantiner, ok := any(store).(objectstore.PackQuarantiner) + if !ok { + t.Fatal("packed store does not implement PackQuarantiner") + } + + quarantine, err := quarantiner.BeginPackQuarantine(objectstore.PackQuarantineOptions{}) + if err != nil { + t.Fatalf("BeginPackQuarantine: %v", err) + } + + err = quarantine.WritePack(bytes.NewReader(packBytes), objectstore.PackWriteOptions{RequireTrailingEOF: true}) + if err != nil { + t.Fatalf("quarantine.WritePack: %v", err) + } + + ty, _, err := quarantine.ReadHeader(head) + if err != nil { + t.Fatalf("quarantine.ReadHeader: %v", err) + } + + if ty != objecttype.TypeCommit { + t.Fatalf("quarantine.ReadHeader type = %v, want commit", ty) + } + + _, _, err = store.ReadHeader(head) + if err == nil { + t.Fatal("store.ReadHeader 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, _, err = store.ReadHeader(head) + if err != nil { + t.Fatalf("store.ReadHeader after promote: %v", err) + } + + if ty != objecttype.TypeCommit { + t.Fatalf("store.ReadHeader type = %v, want commit", ty) + } + }) +} + +func TestPackQuarantineDiscardDropsWrittenObjects(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + head := fixtureOID(t, algo, "head") + packBytes := fixtureBytes(t, algo, "nonthin.pack") + + repo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + packRoot := repo.OpenPackRoot(t) + + store, err := packed.New(packRoot, algo, packed.Options{WriteRev: true}) + if err != nil { + t.Fatalf("packed.New: %v", err) + } + + defer func() { + err := store.Close() + if err != nil { + t.Fatalf("store.Close: %v", err) + } + }() + + quarantiner, ok := any(store).(objectstore.PackQuarantiner) + if !ok { + t.Fatalf("expected objectstore.PackQuarantiner") + } + + quarantine, err := quarantiner.BeginPackQuarantine(objectstore.PackQuarantineOptions{}) + if err != nil { + t.Fatalf("BeginPackQuarantine: %v", err) + } + + err = quarantine.WritePack(bytes.NewReader(packBytes), objectstore.PackWriteOptions{RequireTrailingEOF: true}) + if err != nil { + t.Fatalf("quarantine.WritePack: %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.ReadHeader(head) + if err == nil { + t.Fatal("store.ReadHeader unexpectedly saw discarded object") + } + }) +} |
