diff options
| author | 2026-03-07 21:15:54 +0800 | |
|---|---|---|
| committer | 2026-03-07 21:16:32 +0800 | |
| commit | b82515530f10dfebbf99dca501890570f3466910 (patch) | |
| tree | 9574dc49fa7239f7f0c131471f4a6708fd7041d5 | |
| parent | receivepack: Set permissions properly (diff) | |
| signature | No signature | |
receivepack: Add hooks
| -rw-r--r-- | receivepack/hook.go | 39 | ||||
| -rw-r--r-- | receivepack/int_test.go | 142 | ||||
| -rw-r--r-- | receivepack/internal/service/execute.go | 96 | ||||
| -rw-r--r-- | receivepack/internal/service/hook.go | 30 | ||||
| -rw-r--r-- | receivepack/internal/service/hook_apply.go | 44 | ||||
| -rw-r--r-- | receivepack/internal/service/options.go | 12 | ||||
| -rw-r--r-- | receivepack/internal/service/quarantine_objects.go | 50 | ||||
| -rw-r--r-- | receivepack/options.go | 4 | ||||
| -rw-r--r-- | receivepack/receivepack.go | 39 |
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 + } +} |
