aboutsummaryrefslogtreecommitdiff
path: root/receivepack
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-03-07 21:15:54 +0800
committerGravatar Runxi Yu2026-03-07 21:16:32 +0800
commitb82515530f10dfebbf99dca501890570f3466910 (patch)
tree9574dc49fa7239f7f0c131471f4a6708fd7041d5 /receivepack
parentreceivepack: Set permissions properly (diff)
signatureNo signature
receivepack: Add hooks
Diffstat (limited to 'receivepack')
-rw-r--r--receivepack/hook.go39
-rw-r--r--receivepack/int_test.go142
-rw-r--r--receivepack/internal/service/execute.go96
-rw-r--r--receivepack/internal/service/hook.go30
-rw-r--r--receivepack/internal/service/hook_apply.go44
-rw-r--r--receivepack/internal/service/options.go12
-rw-r--r--receivepack/internal/service/quarantine_objects.go50
-rw-r--r--receivepack/options.go4
-rw-r--r--receivepack/receivepack.go39
9 files changed, 441 insertions, 15 deletions
diff --git a/receivepack/hook.go b/receivepack/hook.go
new file mode 100644
index 00000000..4c4a44ef
--- /dev/null
+++ b/receivepack/hook.go
@@ -0,0 +1,39 @@
+package receivepack
+
+import (
+ "context"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+// RefUpdate is one requested reference update presented to a receive-pack hook.
+type RefUpdate struct {
+ Name string
+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+}
+
+// UpdateDecision is one hook decision for a requested reference update.
+type UpdateDecision struct {
+ Accept bool
+ Message string
+}
+
+// HookRequest is the input presented to a receive-pack hook before quarantine
+// promotion and ref updates.
+type HookRequest struct {
+ Refs refstore.ReadingStore
+ ExistingObjects objectstore.Store
+ QuarantinedObjects objectstore.Store
+ Updates []RefUpdate
+ PushOptions []string
+}
+
+// Hook decides whether each requested update should proceed.
+//
+// The hook runs after pack ingestion into quarantine and before quarantine
+// promotion or ref updates. The returned decisions must have the same length as
+// HookRequest.Updates.
+type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
diff --git a/receivepack/int_test.go b/receivepack/int_test.go
index 8f0d02e6..e05a9bda 100644
--- a/receivepack/int_test.go
+++ b/receivepack/int_test.go
@@ -403,6 +403,148 @@ func TestReceivePackPackCreatePromotesObjectsAndUpdatesRef(t *testing.T) {
})
}
+func TestReceivePackHookSeesQuarantinedObjectsAndCanRejectBeforePromotion(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := sender.MakeCommit(t, "pushed commit")
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ packStream := sender.PackObjectsReader(t, []string{commitID.String()}, false)
+ t.Cleanup(func() {
+ _ = packStream.Close()
+ })
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ hookCalled bool
+ )
+
+ input.WriteString(pktlineData(
+ objectid.Zero(algo).String() + " " + commitID.String() + " refs/heads/main\x00report-status-v2 atomic object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(
+ context.Background(),
+ &output,
+ io.MultiReader(strings.NewReader(input.String()), packStream),
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) {
+ hookCalled = true
+
+ if len(req.Updates) != 1 || req.Updates[0].NewID != commitID {
+ t.Fatalf("unexpected hook updates: %+v", req.Updates)
+ }
+
+ if _, _, err := req.ExistingObjects.ReadHeader(commitID); err == nil {
+ t.Fatalf("existing objects unexpectedly contained quarantined commit %s", commitID)
+ }
+
+ if _, _, err := req.QuarantinedObjects.ReadHeader(commitID); err != nil {
+ t.Fatalf("quarantined objects missing commit %s: %v", commitID, err)
+ }
+
+ return []receivepack.UpdateDecision{{
+ Accept: false,
+ Message: "blocked by hook",
+ }}, nil
+ },
+ },
+ )
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ if !hookCalled {
+ t.Fatal("hook was not called")
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "unpack ok\n") || !strings.Contains(got, "ng refs/heads/main blocked by hook\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/main"); err == nil {
+ t.Fatal("refs/heads/main exists after hook rejection")
+ }
+
+ packs := receiver.Run(t, "count-objects", "-v")
+ if !strings.Contains(packs, "packs: 0") {
+ t.Fatalf("count-objects output shows unexpected promoted pack: %q", packs)
+ }
+ })
+}
+
+func TestReceivePackHookCanRejectSubsetOfNonAtomicDeleteOnlyPush(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/topic\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) {
+ return []receivepack.UpdateDecision{
+ {Accept: false, Message: "leave main alone"},
+ {Accept: true},
+ }, nil
+ },
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main leave main alone\n") || !strings.Contains(got, "ok refs/heads/topic\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/main"); err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ if _, err := repo.Refs().Resolve("refs/heads/topic"); err == nil {
+ t.Fatal("refs/heads/topic still exists after successful delete")
+ }
+ })
+}
+
func TestReceivePackReportStatusV2IncludesRefDetails(t *testing.T) {
t.Parallel()
diff --git a/receivepack/internal/service/execute.go b/receivepack/internal/service/execute.go
index ebba9003..8febfc79 100644
--- a/receivepack/internal/service/execute.go
+++ b/receivepack/internal/service/execute.go
@@ -8,13 +8,8 @@ import (
)
// Execute validates one receive-pack request, optionally ingests its pack into
-// quarantine, and plans ref updates.
-//
-// TODO: Invoke hook or policy callbacks to decide whether each planned update
-// should be allowed.
+// quarantine, runs the optional hook, and applies allowed ref updates.
func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) {
- _ = ctx
-
result := &Result{
Commands: make([]CommandResult, 0, len(req.Commands)),
}
@@ -95,6 +90,83 @@ func (service *Service) Execute(ctx context.Context, req *Request) (*Result, err
return result, nil
}
+ allowedCommands := append([]Command(nil), req.Commands...)
+ allowedIndices := make([]int, 0, len(req.Commands))
+ for index := range req.Commands {
+ allowedIndices = append(allowedIndices, index)
+ }
+ rejected := make(map[int]string)
+
+ if service.opts.Hook != nil {
+ quarantinedObjects, err := service.openQuarantinedObjects(quarantineName)
+ if err != nil {
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+
+ defer func() {
+ _ = quarantinedObjects.Close()
+ }()
+
+ decisions, err := service.opts.Hook(ctx, HookRequest{
+ Refs: service.opts.Refs,
+ ExistingObjects: service.opts.ExistingObjects,
+ QuarantinedObjects: quarantinedObjects,
+ Updates: buildHookUpdates(req.Commands),
+ PushOptions: append([]string(nil), req.PushOptions...),
+ })
+ if err != nil {
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+
+ if len(decisions) != len(req.Commands) {
+ fillCommandErrors(result, req.Commands, "hook returned wrong number of update decisions")
+
+ return result, nil
+ }
+
+ allowedCommands = allowedCommands[:0]
+ allowedIndices = allowedIndices[:0]
+ for index, decision := range decisions {
+ if decision.Accept {
+ allowedCommands = append(allowedCommands, req.Commands[index])
+ allowedIndices = append(allowedIndices, index)
+
+ continue
+ }
+
+ message := decision.Message
+ if message == "" {
+ message = "rejected by hook"
+ }
+
+ rejected[index] = message
+ }
+
+ if req.Atomic && len(rejected) != 0 {
+ result.Commands = make([]CommandResult, 0, len(req.Commands))
+ for index, command := range req.Commands {
+ message := rejected[index]
+ if message == "" {
+ message = "atomic push rejected by hook"
+ }
+
+ result.Commands = append(result.Commands, resultForHookRejection(command, message))
+ }
+
+ return result, nil
+ }
+ }
+
+ if len(allowedCommands) == 0 {
+ result.Commands = mergeCommandResults(req.Commands, rejected, nil, nil)
+
+ return result, nil
+ }
+
if req.PackExpected {
// Git migrates quarantined objects into permanent storage immediately
// before starting ref updates.
@@ -108,18 +180,26 @@ func (service *Service) Execute(ctx context.Context, req *Request) (*Result, err
}
if req.Atomic {
- err := service.applyAtomic(result, req.Commands)
+ subresult := &Result{}
+ err := service.applyAtomic(subresult, allowedCommands)
if err != nil {
return result, err
}
+ result.Commands = mergeCommandResults(req.Commands, rejected, subresult.Commands, allowedIndices)
+ result.Applied = subresult.Applied
+
return result, nil
}
- err = service.applyBatch(result, req.Commands)
+ subresult := &Result{}
+ err = service.applyBatch(subresult, allowedCommands)
if err != nil {
return result, err
}
+ result.Commands = mergeCommandResults(req.Commands, rejected, subresult.Commands, allowedIndices)
+ result.Applied = subresult.Applied
+
return result, nil
}
diff --git a/receivepack/internal/service/hook.go b/receivepack/internal/service/hook.go
new file mode 100644
index 00000000..85295e15
--- /dev/null
+++ b/receivepack/internal/service/hook.go
@@ -0,0 +1,30 @@
+package service
+
+import (
+ "context"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+type RefUpdate struct {
+ Name string
+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+}
+
+type UpdateDecision struct {
+ Accept bool
+ Message string
+}
+
+type HookRequest struct {
+ Refs refstore.ReadingStore
+ ExistingObjects objectstore.Store
+ QuarantinedObjects objectstore.Store
+ Updates []RefUpdate
+ PushOptions []string
+}
+
+type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
diff --git a/receivepack/internal/service/hook_apply.go b/receivepack/internal/service/hook_apply.go
new file mode 100644
index 00000000..5bd8f596
--- /dev/null
+++ b/receivepack/internal/service/hook_apply.go
@@ -0,0 +1,44 @@
+package service
+
+func buildHookUpdates(commands []Command) []RefUpdate {
+ updates := make([]RefUpdate, 0, len(commands))
+ for _, command := range commands {
+ updates = append(updates, RefUpdate{
+ Name: command.Name,
+ OldID: command.OldID,
+ NewID: command.NewID,
+ })
+ }
+
+ return updates
+}
+
+func resultForHookRejection(command Command, message string) CommandResult {
+ result := successCommandResult(command)
+ result.Error = message
+
+ return result
+}
+
+func mergeCommandResults(
+ commands []Command,
+ rejected map[int]string,
+ applied []CommandResult,
+ appliedIndices []int,
+) []CommandResult {
+ out := make([]CommandResult, len(commands))
+
+ for index, message := range rejected {
+ out[index] = resultForHookRejection(commands[index], message)
+ }
+
+ for i, appliedResult := range applied {
+ if i >= len(appliedIndices) {
+ break
+ }
+
+ out[appliedIndices[i]] = appliedResult
+ }
+
+ return out
+}
diff --git a/receivepack/internal/service/options.go b/receivepack/internal/service/options.go
index c3d6c961..d9f8a265 100644
--- a/receivepack/internal/service/options.go
+++ b/receivepack/internal/service/options.go
@@ -16,10 +16,10 @@ type PromotedObjectPermissions struct {
// Options configures one protocol-independent receive-pack service.
type Options struct {
- Algorithm objectid.Algorithm
- Refs refstore.ReadWriteStore
- ExistingObjects objectstore.Store
- ObjectsRoot *os.Root
- PromotedObjectPermissions *PromotedObjectPermissions
- // TODO: Hook and such callbacks.
+ Algorithm objectid.Algorithm
+ Refs refstore.ReadWriteStore
+ ExistingObjects objectstore.Store
+ ObjectsRoot *os.Root
+ PromotedObjectPermissions *PromotedObjectPermissions
+ Hook Hook
}
diff --git a/receivepack/internal/service/quarantine_objects.go b/receivepack/internal/service/quarantine_objects.go
new file mode 100644
index 00000000..0c03be51
--- /dev/null
+++ b/receivepack/internal/service/quarantine_objects.go
@@ -0,0 +1,50 @@
+package service
+
+import (
+ "os"
+
+ "codeberg.org/lindenii/furgit/objectstore"
+ objectmix "codeberg.org/lindenii/furgit/objectstore/mix"
+ "codeberg.org/lindenii/furgit/objectstore/memory"
+ "codeberg.org/lindenii/furgit/objectstore/loose"
+ "codeberg.org/lindenii/furgit/objectstore/packed"
+)
+
+func (service *Service) openQuarantinedObjects(quarantineName string) (objectstore.Store, error) {
+ if quarantineName == "" {
+ return memory.New(service.opts.Algorithm), nil
+ }
+
+ looseRoot, err := service.opts.ObjectsRoot.OpenRoot(quarantineName)
+ if err != nil {
+ return nil, err
+ }
+
+ looseStore, err := loose.New(looseRoot, service.opts.Algorithm)
+ if err != nil {
+ _ = looseRoot.Close()
+
+ return nil, err
+ }
+
+ packRoot, err := looseRoot.OpenRoot("pack")
+ if err == nil {
+ packedStore, packedErr := packed.New(packRoot, service.opts.Algorithm)
+ if packedErr != nil {
+ _ = packRoot.Close()
+ _ = looseStore.Close()
+
+ return nil, packedErr
+ }
+
+ return objectmix.New(looseStore, packedStore), nil
+ }
+
+ if !os.IsNotExist(err) {
+ _ = looseStore.Close()
+
+ return nil, err
+ }
+
+ return looseStore, nil
+}
diff --git a/receivepack/options.go b/receivepack/options.go
index 76b908fd..e39f83ba 100644
--- a/receivepack/options.go
+++ b/receivepack/options.go
@@ -34,7 +34,9 @@ type Options struct {
// PromotedObjectPermissions, when non-nil, is applied to objects and
// directories moved from quarantine into the permanent object store.
PromotedObjectPermissions *PromotedObjectPermissions
- // TODO: Hook and policy callbacks.
+ // Hook, when non-nil, runs after pack ingestion into quarantine and before
+ // quarantine promotion or ref updates.
+ Hook Hook
}
func validateOptions(opts Options) error {
diff --git a/receivepack/receivepack.go b/receivepack/receivepack.go
index 5a70715d..eb518935 100644
--- a/receivepack/receivepack.go
+++ b/receivepack/receivepack.go
@@ -74,6 +74,7 @@ func ReceivePack(
PromotedObjectPermissions: translatePromotedObjectPermissions(
opts.PromotedObjectPermissions,
),
+ Hook: translateHook(opts.Hook),
})
result, err := svc.Execute(ctx, serviceReq)
@@ -106,3 +107,41 @@ func translatePromotedObjectPermissions(
FileMode: perms.FileMode,
}
}
+
+func translateHook(hook Hook) service.Hook {
+ if hook == nil {
+ return nil
+ }
+
+ return func(ctx context.Context, req service.HookRequest) ([]service.UpdateDecision, error) {
+ translatedUpdates := make([]RefUpdate, 0, len(req.Updates))
+ for _, update := range req.Updates {
+ translatedUpdates = append(translatedUpdates, RefUpdate{
+ Name: update.Name,
+ OldID: update.OldID,
+ NewID: update.NewID,
+ })
+ }
+
+ decisions, err := hook(ctx, HookRequest{
+ Refs: req.Refs,
+ ExistingObjects: req.ExistingObjects,
+ QuarantinedObjects: req.QuarantinedObjects,
+ Updates: translatedUpdates,
+ PushOptions: append([]string(nil), req.PushOptions...),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]service.UpdateDecision, 0, len(decisions))
+ for _, decision := range decisions {
+ out = append(out, service.UpdateDecision{
+ Accept: decision.Accept,
+ Message: decision.Message,
+ })
+ }
+
+ return out, nil
+ }
+}