From 0fb1520c3119ed6aedc5cb25098c0fd0b4cacf90 Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Sat, 7 Mar 2026 22:38:17 +0800 Subject: *: Package sorting and updates --- protocol/doc.go | 2 + protocol/v0v1/doc.go | 2 + receivepack/commands.go | 2 +- receivepack/hook.go | 2 +- receivepack/internal/service/apply.go | 108 --------- receivepack/internal/service/command.go | 32 --- receivepack/internal/service/command_result.go | 13 - receivepack/internal/service/doc.go | 2 - receivepack/internal/service/execute.go | 115 --------- receivepack/internal/service/hook.go | 37 --- receivepack/internal/service/hook_apply.go | 44 ---- receivepack/internal/service/ingest_quarantine.go | 75 ------ receivepack/internal/service/options.go | 26 -- receivepack/internal/service/quarantine.go | 269 --------------------- receivepack/internal/service/quarantine_objects.go | 50 ---- receivepack/internal/service/quarantine_test.go | 184 -------------- receivepack/internal/service/request.go | 13 - receivepack/internal/service/result.go | 14 -- receivepack/internal/service/run_hook.go | 74 ------ receivepack/internal/service/service.go | 11 - receivepack/internal/service/service_test.go | 99 -------- receivepack/internal/service/update.go | 12 - receivepack/permissions.go | 2 +- receivepack/receivepack.go | 2 +- receivepack/results.go | 2 +- receivepack/service/apply.go | 108 +++++++++ receivepack/service/command.go | 32 +++ receivepack/service/command_result.go | 13 + receivepack/service/doc.go | 2 + receivepack/service/execute.go | 115 +++++++++ receivepack/service/hook.go | 37 +++ receivepack/service/hook_apply.go | 44 ++++ receivepack/service/ingest_quarantine.go | 75 ++++++ receivepack/service/options.go | 26 ++ receivepack/service/quarantine.go | 269 +++++++++++++++++++++ receivepack/service/quarantine_objects.go | 50 ++++ receivepack/service/quarantine_test.go | 184 ++++++++++++++ receivepack/service/request.go | 13 + receivepack/service/result.go | 14 ++ receivepack/service/run_hook.go | 74 ++++++ receivepack/service/service.go | 11 + receivepack/service/service_test.go | 99 ++++++++ receivepack/service/update.go | 12 + 43 files changed, 1187 insertions(+), 1183 deletions(-) create mode 100644 protocol/doc.go create mode 100644 protocol/v0v1/doc.go delete mode 100644 receivepack/internal/service/apply.go delete mode 100644 receivepack/internal/service/command.go delete mode 100644 receivepack/internal/service/command_result.go delete mode 100644 receivepack/internal/service/doc.go delete mode 100644 receivepack/internal/service/execute.go delete mode 100644 receivepack/internal/service/hook.go delete mode 100644 receivepack/internal/service/hook_apply.go delete mode 100644 receivepack/internal/service/ingest_quarantine.go delete mode 100644 receivepack/internal/service/options.go delete mode 100644 receivepack/internal/service/quarantine.go delete mode 100644 receivepack/internal/service/quarantine_objects.go delete mode 100644 receivepack/internal/service/quarantine_test.go delete mode 100644 receivepack/internal/service/request.go delete mode 100644 receivepack/internal/service/result.go delete mode 100644 receivepack/internal/service/run_hook.go delete mode 100644 receivepack/internal/service/service.go delete mode 100644 receivepack/internal/service/service_test.go delete mode 100644 receivepack/internal/service/update.go create mode 100644 receivepack/service/apply.go create mode 100644 receivepack/service/command.go create mode 100644 receivepack/service/command_result.go create mode 100644 receivepack/service/doc.go create mode 100644 receivepack/service/execute.go create mode 100644 receivepack/service/hook.go create mode 100644 receivepack/service/hook_apply.go create mode 100644 receivepack/service/ingest_quarantine.go create mode 100644 receivepack/service/options.go create mode 100644 receivepack/service/quarantine.go create mode 100644 receivepack/service/quarantine_objects.go create mode 100644 receivepack/service/quarantine_test.go create mode 100644 receivepack/service/request.go create mode 100644 receivepack/service/result.go create mode 100644 receivepack/service/run_hook.go create mode 100644 receivepack/service/service.go create mode 100644 receivepack/service/service_test.go create mode 100644 receivepack/service/update.go diff --git a/protocol/doc.go b/protocol/doc.go new file mode 100644 index 00000000..d1e00447 --- /dev/null +++ b/protocol/doc.go @@ -0,0 +1,2 @@ +// Package protocol encapsulates network protocol implementations. +package protocol diff --git a/protocol/v0v1/doc.go b/protocol/v0v1/doc.go new file mode 100644 index 00000000..2c96ea23 --- /dev/null +++ b/protocol/v0v1/doc.go @@ -0,0 +1,2 @@ +// Package v0v1 provides common constants and routines for the V0 and V1 protocols. +package v0v1 diff --git a/receivepack/commands.go b/receivepack/commands.go index 399a0bf6..193a47cb 100644 --- a/receivepack/commands.go +++ b/receivepack/commands.go @@ -2,7 +2,7 @@ package receivepack import ( protoreceive "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack" - "codeberg.org/lindenii/furgit/receivepack/internal/service" + "codeberg.org/lindenii/furgit/receivepack/service" ) func translateCommands(commands []protoreceive.Command) []service.Command { diff --git a/receivepack/hook.go b/receivepack/hook.go index bdb0b087..5f98a538 100644 --- a/receivepack/hook.go +++ b/receivepack/hook.go @@ -6,7 +6,7 @@ import ( "codeberg.org/lindenii/furgit/objectid" "codeberg.org/lindenii/furgit/objectstore" - "codeberg.org/lindenii/furgit/receivepack/internal/service" + "codeberg.org/lindenii/furgit/receivepack/service" "codeberg.org/lindenii/furgit/refstore" ) diff --git a/receivepack/internal/service/apply.go b/receivepack/internal/service/apply.go deleted file mode 100644 index f802e0e8..00000000 --- a/receivepack/internal/service/apply.go +++ /dev/null @@ -1,108 +0,0 @@ -package service - -import ( - "codeberg.org/lindenii/furgit/objectid" - "codeberg.org/lindenii/furgit/refstore" -) - -func (service *Service) applyAtomic(result *Result, commands []Command) error { - tx, err := service.opts.Refs.BeginTransaction() - if err != nil { - return err - } - - for _, command := range commands { - err = queueWriteTransaction(tx, command) - if err != nil { - _ = tx.Abort() - - fillCommandErrors(result, commands, err.Error()) - - return nil - } - } - - err = tx.Commit() - if err != nil { - fillCommandErrors(result, commands, err.Error()) - - return nil - } - - result.Applied = true - for _, command := range commands { - result.Commands = append(result.Commands, successCommandResult(command)) - } - - return nil -} - -func (service *Service) applyBatch(result *Result, commands []Command) error { - batch, err := service.opts.Refs.BeginBatch() - if err != nil { - return err - } - - for _, command := range commands { - queueWriteBatch(batch, command) - } - - batchResults, err := batch.Apply() - if err != nil && len(batchResults) == 0 { - return err - } - - appliedAny := false - - for i, command := range commands { - item := successCommandResult(command) - if i < len(batchResults) && batchResults[i].Error != nil { - item.Error = batchResults[i].Error.Error() - } else { - appliedAny = true - } - - result.Commands = append(result.Commands, item) - } - - result.Applied = appliedAny - - return nil -} - -func queueWriteTransaction(tx refstore.Transaction, command Command) error { - if isDelete(command) { - return tx.Delete(command.Name, command.OldID) - } - - if command.OldID == objectid.Zero(command.OldID.Algorithm()) { - return tx.Create(command.Name, command.NewID) - } - - return tx.Update(command.Name, command.NewID, command.OldID) -} - -func queueWriteBatch(batch refstore.Batch, command Command) { - if isDelete(command) { - batch.Delete(command.Name, command.OldID) - - return - } - - if command.OldID == objectid.Zero(command.OldID.Algorithm()) { - batch.Create(command.Name, command.NewID) - - return - } - - batch.Update(command.Name, command.NewID, command.OldID) -} - -func successCommandResult(command Command) CommandResult { - return CommandResult{ - Name: command.Name, - RefName: command.Name, - OldID: objectIDPointer(command.OldID), - NewID: objectIDPointer(command.NewID), - } -} diff --git a/receivepack/internal/service/command.go b/receivepack/internal/service/command.go deleted file mode 100644 index 33342e41..00000000 --- a/receivepack/internal/service/command.go +++ /dev/null @@ -1,32 +0,0 @@ -package service - -import "codeberg.org/lindenii/furgit/objectid" - -// Command is one protocol-independent requested ref update. -type Command struct { - OldID objectid.ObjectID - NewID objectid.ObjectID - Name string -} - -func fillCommandErrors(result *Result, commands []Command, errText string) { - for _, command := range commands { - result.Commands = append(result.Commands, CommandResult{ - Name: command.Name, - Error: errText, - RefName: command.Name, - OldID: objectIDPointer(command.OldID), - NewID: objectIDPointer(command.NewID), - }) - } -} - -func isDelete(command Command) bool { - return command.NewID == objectid.Zero(command.NewID.Algorithm()) -} - -func objectIDPointer(id objectid.ObjectID) *objectid.ObjectID { - out := id - - return &out -} diff --git a/receivepack/internal/service/command_result.go b/receivepack/internal/service/command_result.go deleted file mode 100644 index 18e39acc..00000000 --- a/receivepack/internal/service/command_result.go +++ /dev/null @@ -1,13 +0,0 @@ -package service - -import "codeberg.org/lindenii/furgit/objectid" - -// CommandResult is one per-command execution result. -type CommandResult struct { - Name string - Error string - RefName string - OldID *objectid.ObjectID - NewID *objectid.ObjectID - ForcedUpdate bool -} diff --git a/receivepack/internal/service/doc.go b/receivepack/internal/service/doc.go deleted file mode 100644 index 2bb15a38..00000000 --- a/receivepack/internal/service/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package service implements the protocol-independent receive-pack service. -package service diff --git a/receivepack/internal/service/execute.go b/receivepack/internal/service/execute.go deleted file mode 100644 index 14468799..00000000 --- a/receivepack/internal/service/execute.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "os" -) - -// Execute validates one receive-pack request, optionally ingests its pack into -// quarantine, runs the optional hook, and applies allowed ref updates. -func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) { - result := &Result{ - Commands: make([]CommandResult, 0, len(req.Commands)), - } - - var ( - quarantineName string - quarantineRoot *os.Root - err error - ) - - quarantineName, quarantineRoot, ok := service.ingestQuarantine(result, req.Commands, req) - if !ok { - return result, nil - } - - if quarantineRoot != nil { - defer func() { - _ = quarantineRoot.Close() - _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) - }() - } - - for _, command := range req.Commands { - result.Planned = append(result.Planned, PlannedUpdate{ - Name: command.Name, - OldID: command.OldID, - NewID: command.NewID, - Delete: isDelete(command), - }) - } - - if len(req.Commands) == 0 { - return result, nil - } - - allowedCommands, allowedIndices, rejected, ok, errText := service.runHook( - ctx, - req, - req.Commands, - quarantineName, - ) - if !ok { - fillCommandErrors(result, req.Commands, errText) - - return result, nil - } - - 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. - err = service.promoteQuarantine(quarantineName, quarantineRoot) - if err != nil { - result.UnpackError = err.Error() - fillCommandErrors(result, req.Commands, err.Error()) - - return result, nil - } - } - - if req.Atomic { - 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 - } - - 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 deleted file mode 100644 index 748a00b9..00000000 --- a/receivepack/internal/service/hook.go +++ /dev/null @@ -1,37 +0,0 @@ -package service - -import ( - "context" - "io" - - "codeberg.org/lindenii/furgit/objectid" - "codeberg.org/lindenii/furgit/objectstore" - "codeberg.org/lindenii/furgit/refstore" -) - -type HookIO struct { - Progress io.Writer - Error io.Writer -} - -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 - IO HookIO -} - -type Hook func(context.Context, HookRequest) ([]UpdateDecision, error) diff --git a/receivepack/internal/service/hook_apply.go b/receivepack/internal/service/hook_apply.go deleted file mode 100644 index 5bd8f596..00000000 --- a/receivepack/internal/service/hook_apply.go +++ /dev/null @@ -1,44 +0,0 @@ -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/ingest_quarantine.go b/receivepack/internal/service/ingest_quarantine.go deleted file mode 100644 index bf918c6d..00000000 --- a/receivepack/internal/service/ingest_quarantine.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "os" - - "codeberg.org/lindenii/furgit/format/pack/ingest" -) - -func (service *Service) ingestQuarantine( - result *Result, - commands []Command, - req *Request, -) (string, *os.Root, bool) { - if !req.PackExpected { - return "", nil, true - } - - if req.Pack == nil { - result.UnpackError = "missing pack stream" - fillCommandErrors(result, commands, "missing pack stream") - - return "", nil, false - } - - if service.opts.ObjectsRoot == nil { - result.UnpackError = "objects root not configured" - fillCommandErrors(result, commands, "objects root not configured") - - return "", nil, false - } - - quarantineName, quarantineRoot, err := service.createQuarantineRoot() - if err != nil { - result.UnpackError = err.Error() - fillCommandErrors(result, commands, err.Error()) - - return "", nil, false - } - - quarantinePackRoot, err := service.openQuarantinePackRoot(quarantineRoot) - if err != nil { - result.UnpackError = err.Error() - fillCommandErrors(result, commands, err.Error()) - - _ = quarantineRoot.Close() - _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) - - return "", nil, false - } - - ingested, err := ingest.Ingest( - req.Pack, - quarantinePackRoot, - service.opts.Algorithm, - true, - true, - service.opts.ExistingObjects, - ) - - _ = quarantinePackRoot.Close() - - if err != nil { - result.UnpackError = err.Error() - fillCommandErrors(result, commands, err.Error()) - - _ = quarantineRoot.Close() - _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) - - return "", nil, false - } - - result.Ingest = &ingested - - return quarantineName, quarantineRoot, true -} diff --git a/receivepack/internal/service/options.go b/receivepack/internal/service/options.go deleted file mode 100644 index b8dda2f7..00000000 --- a/receivepack/internal/service/options.go +++ /dev/null @@ -1,26 +0,0 @@ -package service - -import ( - "io/fs" - "os" - - "codeberg.org/lindenii/furgit/objectid" - "codeberg.org/lindenii/furgit/objectstore" - "codeberg.org/lindenii/furgit/refstore" -) - -type PromotedObjectPermissions struct { - DirMode fs.FileMode - FileMode fs.FileMode -} - -// 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 - Hook Hook - HookIO HookIO -} diff --git a/receivepack/internal/service/quarantine.go b/receivepack/internal/service/quarantine.go deleted file mode 100644 index 97a85959..00000000 --- a/receivepack/internal/service/quarantine.go +++ /dev/null @@ -1,269 +0,0 @@ -package service - -import ( - "bytes" - "crypto/rand" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path" - "slices" -) - -// createQuarantineRoot creates one per-push quarantine directory beneath the -// permanent objects root. -func (service *Service) createQuarantineRoot() (string, *os.Root, error) { - name := "tmp_objdir-incoming-" + rand.Text() - - err := service.opts.ObjectsRoot.Mkdir(name, 0o700) - if err != nil { - return "", nil, err - } - - root, err := service.opts.ObjectsRoot.OpenRoot(name) - if err != nil { - _ = service.opts.ObjectsRoot.RemoveAll(name) - - return "", nil, err - } - - return name, root, nil -} - -func (service *Service) openQuarantinePackRoot(quarantineRoot *os.Root) (*os.Root, error) { - err := quarantineRoot.Mkdir("pack", 0o755) - if err != nil && !os.IsExist(err) { - return nil, err - } - - return quarantineRoot.OpenRoot("pack") -} - -func (service *Service) promoteQuarantine(quarantineName string, quarantineRoot *os.Root) error { - if quarantineName == "" || quarantineRoot == nil { - return nil - } - - return service.promoteQuarantineDir(quarantineName, quarantineRoot, ".") -} - -func (service *Service) promoteQuarantineDir(quarantineName string, quarantineRoot *os.Root, rel string) error { - entries, err := fs.ReadDir(quarantineRoot.FS(), rel) - if err != nil && !os.IsNotExist(err) { - return err - } - - slices.SortFunc(entries, func(left, right fs.DirEntry) int { - return packCopyPriority(left.Name()) - packCopyPriority(right.Name()) - }) - - for _, entry := range entries { - childRel := entry.Name() - if rel != "." { - childRel = path.Join(rel, entry.Name()) - } - - if entry.IsDir() { - err = service.opts.ObjectsRoot.Mkdir(childRel, 0o755) - if err != nil && !os.IsExist(err) { - return err - } - - err = service.applyPromotedDirectoryPermissions(childRel) - if err != nil { - return err - } - - err = service.promoteQuarantineDir(quarantineName, quarantineRoot, childRel) - if err != nil { - return err - } - - continue - } - - err = finalizeQuarantineFile( - service.opts.ObjectsRoot, - path.Join(quarantineName, childRel), - childRel, - isLooseObjectShardPath(rel), - service.opts.PromotedObjectPermissions, - ) - if err == nil { - continue - } - - return err - } - - return nil -} - -func packCopyPriority(name string) int { - if !pathHasPackPrefix(name) { - return 0 - } - - switch { - case path.Ext(name) == ".keep": - return 1 - case path.Ext(name) == ".pack": - return 2 - case path.Ext(name) == ".rev": - return 3 - case path.Ext(name) == ".idx": - return 4 - default: - return 5 - } -} - -func pathHasPackPrefix(name string) bool { - return len(name) >= 4 && name[:4] == "pack" -} - -func isLooseObjectShardPath(rel string) bool { - return len(rel) == 2 && isHex(rel[0]) && isHex(rel[1]) -} - -func isHex(ch byte) bool { - return ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'f') || ('A' <= ch && ch <= 'F') -} - -func (service *Service) applyPromotedDirectoryPermissions(name string) error { - if service.opts.PromotedObjectPermissions == nil { - return nil - } - - return service.opts.ObjectsRoot.Chmod(name, service.opts.PromotedObjectPermissions.DirMode) -} - -func applyPromotedFilePermissions( - root *os.Root, - name string, - perms *PromotedObjectPermissions, -) error { - if perms == nil { - return nil - } - - return root.Chmod(name, perms.FileMode) -} - -func finalizeQuarantineFile( - root *os.Root, - src, dst string, - skipCollisionCheck bool, - perms *PromotedObjectPermissions, -) error { - const maxVanishedRetries = 5 - - for retries := 0; ; retries++ { - err := root.Link(src, dst) - switch { - case err == nil: - _ = root.Remove(src) - - return applyPromotedFilePermissions(root, dst, perms) - case !errors.Is(err, fs.ErrExist): - _, statErr := root.Stat(dst) - switch { - case statErr == nil: - err = fs.ErrExist - case errors.Is(statErr, fs.ErrNotExist): - renameErr := root.Rename(src, dst) - if renameErr == nil { - return applyPromotedFilePermissions(root, dst, perms) - } - - err = renameErr - default: - _ = root.Remove(src) - - return statErr - } - } - - if !errors.Is(err, fs.ErrExist) { - _ = root.Remove(src) - - return fmt.Errorf("promote quarantine %q -> %q: %w", src, dst, err) - } - - if skipCollisionCheck { - _ = root.Remove(src) - - return applyPromotedFilePermissions(root, dst, perms) - } - - equal, vanished, cmpErr := compareRootFiles(root, src, dst) - if vanished { - if retries >= maxVanishedRetries { - return fmt.Errorf("promote quarantine %q -> %q: destination repeatedly vanished", src, dst) - } - - continue - } - - if cmpErr != nil { - return cmpErr - } - - if !equal { - return fmt.Errorf("promote quarantine %q -> %q: files differ in contents", src, dst) - } - - _ = root.Remove(src) - - return applyPromotedFilePermissions(root, dst, perms) - } -} - -func compareRootFiles(root *os.Root, left, right string) (equal bool, vanished bool, err error) { - leftFile, err := root.Open(left) - if err != nil { - return false, false, err - } - - defer func() { - _ = leftFile.Close() - }() - - rightFile, err := root.Open(right) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return false, true, nil - } - - return false, false, err - } - - defer func() { - _ = rightFile.Close() - }() - - var leftBuf, rightBuf [4096]byte - - for { - leftN, leftErr := leftFile.Read(leftBuf[:]) - rightN, rightErr := rightFile.Read(rightBuf[:]) - - if leftErr != nil && !errors.Is(leftErr, io.EOF) { - return false, false, leftErr - } - - if rightErr != nil && !errors.Is(rightErr, io.EOF) { - return false, false, rightErr - } - - if leftN != rightN || !bytes.Equal(leftBuf[:leftN], rightBuf[:rightN]) { - return false, false, nil - } - - if leftErr != nil || rightErr != nil { - return true, false, nil - } - } -} diff --git a/receivepack/internal/service/quarantine_objects.go b/receivepack/internal/service/quarantine_objects.go deleted file mode 100644 index 69e07a1d..00000000 --- a/receivepack/internal/service/quarantine_objects.go +++ /dev/null @@ -1,50 +0,0 @@ -package service - -import ( - "os" - - "codeberg.org/lindenii/furgit/objectstore" - "codeberg.org/lindenii/furgit/objectstore/loose" - "codeberg.org/lindenii/furgit/objectstore/memory" - objectmix "codeberg.org/lindenii/furgit/objectstore/mix" - "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/internal/service/quarantine_test.go b/receivepack/internal/service/quarantine_test.go deleted file mode 100644 index 0bab3728..00000000 --- a/receivepack/internal/service/quarantine_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package service //nolint:testpackage - -// because we need access to quarantine internals - -import ( - "os" - "path" - "testing" - - "codeberg.org/lindenii/furgit/objectid" - "codeberg.org/lindenii/furgit/objectstore/memory" -) - -type quarantineFixture struct { - svc *Service - objectsRoot *os.Root - quarantineName string - quarantineRoot *os.Root -} - -func newQuarantineFixture(tb testing.TB, opts Options) *quarantineFixture { - tb.Helper() - - objectsRoot, err := os.OpenRoot(tb.TempDir()) - if err != nil { - tb.Fatalf("os.OpenRoot: %v", err) - } - - tb.Cleanup(func() { - _ = objectsRoot.Close() - }) - - opts.Algorithm = objectid.AlgorithmSHA1 - opts.ExistingObjects = memory.New(objectid.AlgorithmSHA1) - opts.ObjectsRoot = objectsRoot - - svc := New(opts) - - quarantineName, quarantineRoot, err := svc.createQuarantineRoot() - if err != nil { - tb.Fatalf("createQuarantineRoot: %v", err) - } - - tb.Cleanup(func() { - _ = quarantineRoot.Close() - _ = objectsRoot.RemoveAll(quarantineName) - }) - - return &quarantineFixture{ - svc: svc, - objectsRoot: objectsRoot, - quarantineName: quarantineName, - quarantineRoot: quarantineRoot, - } -} - -func writeMatchingPromotedFile( - tb testing.TB, - quarantineRoot, objectsRoot *os.Root, - dir, name, payload string, -) { - tb.Helper() - - err := quarantineRoot.Mkdir(dir, 0o755) - if err != nil { - tb.Fatalf("Mkdir(%s): %v", dir, err) - } - - err = objectsRoot.Mkdir(dir, 0o755) - if err != nil { - tb.Fatalf("Mkdir(dst %s): %v", dir, err) - } - - rel := path.Join(dir, name) - - err = quarantineRoot.WriteFile(rel, []byte(payload), 0o644) - if err != nil { - tb.Fatalf("WriteFile(quarantine %s): %v", rel, err) - } - - err = objectsRoot.WriteFile(rel, []byte(payload), 0o644) - if err != nil { - tb.Fatalf("WriteFile(permanent %s): %v", rel, err) - } -} - -func TestPromoteQuarantineAppliesConfiguredPermissions(t *testing.T) { - t.Parallel() - - fx := newQuarantineFixture(t, Options{ - PromotedObjectPermissions: &PromotedObjectPermissions{ - DirMode: 0o751, - FileMode: 0o640, - }, - }) - - err := fx.quarantineRoot.Mkdir("ab", 0o700) - if err != nil { - t.Fatalf("Mkdir(ab): %v", err) - } - - err = fx.quarantineRoot.WriteFile(path.Join("ab", "cdef"), []byte("payload"), 0o600) - if err != nil { - t.Fatalf("WriteFile(quarantine loose): %v", err) - } - - err = fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) - if err != nil { - t.Fatalf("promoteQuarantine: %v", err) - } - - dirInfo, err := fx.objectsRoot.Stat("ab") - if err != nil { - t.Fatalf("Stat(ab): %v", err) - } - - if got := dirInfo.Mode().Perm(); got != 0o751 { - t.Fatalf("dir mode = %o, want 751", got) - } - - fileInfo, err := fx.objectsRoot.Stat(path.Join("ab", "cdef")) - if err != nil { - t.Fatalf("Stat(ab/cdef): %v", err) - } - - if got := fileInfo.Mode().Perm(); got != 0o640 { - t.Fatalf("file mode = %o, want 640", got) - } -} - -func TestPromoteQuarantineTreatsExistingLooseObjectAsSuccess(t *testing.T) { - t.Parallel() - - fx := newQuarantineFixture(t, Options{}) - writeMatchingPromotedFile(t, fx.quarantineRoot, fx.objectsRoot, "ab", "cdef", "same object bytes") - - err := fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) - if err != nil { - t.Fatalf("promoteQuarantine: %v", err) - } -} - -func TestPromoteQuarantineRejectsDifferentExistingPackFile(t *testing.T) { - t.Parallel() - - fx := newQuarantineFixture(t, Options{}) - - err := fx.quarantineRoot.Mkdir("pack", 0o755) - if err != nil { - t.Fatalf("Mkdir(pack): %v", err) - } - - err = fx.objectsRoot.Mkdir("pack", 0o755) - if err != nil { - t.Fatalf("Mkdir(dst pack): %v", err) - } - - err = fx.quarantineRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("new bytes"), 0o644) - if err != nil { - t.Fatalf("WriteFile(quarantine pack): %v", err) - } - - err = fx.objectsRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("old bytes"), 0o644) - if err != nil { - t.Fatalf("WriteFile(permanent pack): %v", err) - } - - err = fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) - if err == nil { - t.Fatal("promoteQuarantine unexpectedly succeeded") - } -} - -func TestPromoteQuarantineAcceptsMatchingExistingPackFile(t *testing.T) { - t.Parallel() - - fx := newQuarantineFixture(t, Options{}) - writeMatchingPromotedFile(t, fx.quarantineRoot, fx.objectsRoot, "pack", "pack-a.pack", "identical pack bytes") - - err := fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) - if err != nil { - t.Fatalf("promoteQuarantine: %v", err) - } -} diff --git a/receivepack/internal/service/request.go b/receivepack/internal/service/request.go deleted file mode 100644 index 7a0b1f33..00000000 --- a/receivepack/internal/service/request.go +++ /dev/null @@ -1,13 +0,0 @@ -package service - -import "io" - -// Request is one protocol-independent receive-pack execution request. -type Request struct { - Commands []Command - PushOptions []string - Atomic bool - DeleteOnly bool - PackExpected bool - Pack io.Reader -} diff --git a/receivepack/internal/service/result.go b/receivepack/internal/service/result.go deleted file mode 100644 index 7db7dcb1..00000000 --- a/receivepack/internal/service/result.go +++ /dev/null @@ -1,14 +0,0 @@ -package service - -import ( - "codeberg.org/lindenii/furgit/format/pack/ingest" -) - -// Result is one receive-pack execution result. -type Result struct { - UnpackError string - Commands []CommandResult - Ingest *ingest.Result - Planned []PlannedUpdate - Applied bool -} diff --git a/receivepack/internal/service/run_hook.go b/receivepack/internal/service/run_hook.go deleted file mode 100644 index 3c76906e..00000000 --- a/receivepack/internal/service/run_hook.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import "context" - -func (service *Service) runHook( - ctx context.Context, - req *Request, - commands []Command, - quarantineName string, -) ( - allowedCommands []Command, - allowedIndices []int, - rejected map[int]string, - ok bool, - errText string, -) { - allowedCommands = append([]Command(nil), commands...) - - allowedIndices = make([]int, 0, len(commands)) - for index := range commands { - allowedIndices = append(allowedIndices, index) - } - - rejected = make(map[int]string) - if service.opts.Hook == nil { - return allowedCommands, allowedIndices, rejected, true, "" - } - - quarantinedObjects, err := service.openQuarantinedObjects(quarantineName) - if err != nil { - return nil, nil, nil, false, err.Error() - } - - defer func() { - _ = quarantinedObjects.Close() - }() - - decisions, err := service.opts.Hook(ctx, HookRequest{ - Refs: service.opts.Refs, - ExistingObjects: service.opts.ExistingObjects, - QuarantinedObjects: quarantinedObjects, - Updates: buildHookUpdates(commands), - PushOptions: append([]string(nil), req.PushOptions...), - IO: service.opts.HookIO, - }) - if err != nil { - return nil, nil, nil, false, err.Error() - } - - if len(decisions) != len(commands) { - return nil, nil, nil, false, "hook returned wrong number of update decisions" - } - - allowedCommands = allowedCommands[:0] - allowedIndices = allowedIndices[:0] - - for index, decision := range decisions { - if decision.Accept { - allowedCommands = append(allowedCommands, commands[index]) - allowedIndices = append(allowedIndices, index) - - continue - } - - message := decision.Message - if message == "" { - message = "rejected by hook" - } - - rejected[index] = message - } - - return allowedCommands, allowedIndices, rejected, true, "" -} diff --git a/receivepack/internal/service/service.go b/receivepack/internal/service/service.go deleted file mode 100644 index d204e9aa..00000000 --- a/receivepack/internal/service/service.go +++ /dev/null @@ -1,11 +0,0 @@ -package service - -// Service executes protocol-independent receive-pack requests. -type Service struct { - opts Options -} - -// New creates one receive-pack service. -func New(opts Options) *Service { - return &Service{opts: opts} -} diff --git a/receivepack/internal/service/service_test.go b/receivepack/internal/service/service_test.go deleted file mode 100644 index a29e71de..00000000 --- a/receivepack/internal/service/service_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package service_test - -import ( - "context" - "io/fs" - "os" - "strings" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/objectid" - "codeberg.org/lindenii/furgit/objectstore/memory" - "codeberg.org/lindenii/furgit/receivepack/internal/service" -) - -func TestExecutePackExpectedWithoutObjectsRoot(t *testing.T) { - t.Parallel() - - //nolint:thelper - testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { - t.Parallel() - - store := memory.New(algo) - svc := service.New(service.Options{ - Algorithm: algo, - ExistingObjects: store, - }) - - result, err := svc.Execute(context.Background(), &service.Request{ - Commands: []service.Command{{ - Name: "refs/heads/main", - OldID: objectid.Zero(algo), - NewID: objectid.Zero(algo), - }}, - PackExpected: true, - Pack: strings.NewReader("not a pack"), - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - - if result.UnpackError != "objects root not configured" { - t.Fatalf("unexpected unpack error %q", result.UnpackError) - } - }) -} - -func TestExecuteRemovesDerivedQuarantineAfterIngestFailure(t *testing.T) { - t.Parallel() - - //nolint:thelper - testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { - t.Parallel() - - store := memory.New(algo) - objectsDir := t.TempDir() - - objectsRoot, err := os.OpenRoot(objectsDir) - if err != nil { - t.Fatalf("os.OpenRoot: %v", err) - } - - t.Cleanup(func() { - _ = objectsRoot.Close() - }) - - svc := service.New(service.Options{ - Algorithm: algo, - ExistingObjects: store, - ObjectsRoot: objectsRoot, - }) - - result, err := svc.Execute(context.Background(), &service.Request{ - Commands: []service.Command{{ - Name: "refs/heads/main", - OldID: objectid.Zero(algo), - NewID: objectid.Zero(algo), - }}, - PackExpected: true, - Pack: strings.NewReader("not a pack"), - }) - if err != nil { - t.Fatalf("Execute: %v", err) - } - - if result.UnpackError == "" { - t.Fatal("Execute returned empty unpack error for invalid pack") - } - - entries, err := fs.ReadDir(objectsRoot.FS(), ".") - if err != nil { - t.Fatalf("fs.ReadDir: %v", err) - } - - if len(entries) != 0 { - t.Fatalf("objects root still has entries after failed ingest: %d", len(entries)) - } - }) -} diff --git a/receivepack/internal/service/update.go b/receivepack/internal/service/update.go deleted file mode 100644 index c73b73a5..00000000 --- a/receivepack/internal/service/update.go +++ /dev/null @@ -1,12 +0,0 @@ -package service - -import "codeberg.org/lindenii/furgit/objectid" - -// PlannedUpdate is one ref update that would be applied once ref writing -// exists. -type PlannedUpdate struct { - Name string - OldID objectid.ObjectID - NewID objectid.ObjectID - Delete bool -} diff --git a/receivepack/permissions.go b/receivepack/permissions.go index ed30c0ce..55eb5390 100644 --- a/receivepack/permissions.go +++ b/receivepack/permissions.go @@ -3,7 +3,7 @@ package receivepack import ( "io/fs" - "codeberg.org/lindenii/furgit/receivepack/internal/service" + "codeberg.org/lindenii/furgit/receivepack/service" ) // PromotedObjectPermissions configures the destination permissions applied to diff --git a/receivepack/receivepack.go b/receivepack/receivepack.go index 4c1912cf..5c724745 100644 --- a/receivepack/receivepack.go +++ b/receivepack/receivepack.go @@ -7,7 +7,7 @@ import ( "codeberg.org/lindenii/furgit/format/pktline" common "codeberg.org/lindenii/furgit/protocol/v0v1/server" protoreceive "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack" - "codeberg.org/lindenii/furgit/receivepack/internal/service" + "codeberg.org/lindenii/furgit/receivepack/service" ) // TODO: Some more designing to do. In particular, we'd like to have access to diff --git a/receivepack/results.go b/receivepack/results.go index f56fbf2f..c046409e 100644 --- a/receivepack/results.go +++ b/receivepack/results.go @@ -2,7 +2,7 @@ package receivepack import ( protoreceive "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack" - "codeberg.org/lindenii/furgit/receivepack/internal/service" + "codeberg.org/lindenii/furgit/receivepack/service" ) func translateResult(result *service.Result) protoreceive.ReportStatusResult { diff --git a/receivepack/service/apply.go b/receivepack/service/apply.go new file mode 100644 index 00000000..f802e0e8 --- /dev/null +++ b/receivepack/service/apply.go @@ -0,0 +1,108 @@ +package service + +import ( + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/refstore" +) + +func (service *Service) applyAtomic(result *Result, commands []Command) error { + tx, err := service.opts.Refs.BeginTransaction() + if err != nil { + return err + } + + for _, command := range commands { + err = queueWriteTransaction(tx, command) + if err != nil { + _ = tx.Abort() + + fillCommandErrors(result, commands, err.Error()) + + return nil + } + } + + err = tx.Commit() + if err != nil { + fillCommandErrors(result, commands, err.Error()) + + return nil + } + + result.Applied = true + for _, command := range commands { + result.Commands = append(result.Commands, successCommandResult(command)) + } + + return nil +} + +func (service *Service) applyBatch(result *Result, commands []Command) error { + batch, err := service.opts.Refs.BeginBatch() + if err != nil { + return err + } + + for _, command := range commands { + queueWriteBatch(batch, command) + } + + batchResults, err := batch.Apply() + if err != nil && len(batchResults) == 0 { + return err + } + + appliedAny := false + + for i, command := range commands { + item := successCommandResult(command) + if i < len(batchResults) && batchResults[i].Error != nil { + item.Error = batchResults[i].Error.Error() + } else { + appliedAny = true + } + + result.Commands = append(result.Commands, item) + } + + result.Applied = appliedAny + + return nil +} + +func queueWriteTransaction(tx refstore.Transaction, command Command) error { + if isDelete(command) { + return tx.Delete(command.Name, command.OldID) + } + + if command.OldID == objectid.Zero(command.OldID.Algorithm()) { + return tx.Create(command.Name, command.NewID) + } + + return tx.Update(command.Name, command.NewID, command.OldID) +} + +func queueWriteBatch(batch refstore.Batch, command Command) { + if isDelete(command) { + batch.Delete(command.Name, command.OldID) + + return + } + + if command.OldID == objectid.Zero(command.OldID.Algorithm()) { + batch.Create(command.Name, command.NewID) + + return + } + + batch.Update(command.Name, command.NewID, command.OldID) +} + +func successCommandResult(command Command) CommandResult { + return CommandResult{ + Name: command.Name, + RefName: command.Name, + OldID: objectIDPointer(command.OldID), + NewID: objectIDPointer(command.NewID), + } +} diff --git a/receivepack/service/command.go b/receivepack/service/command.go new file mode 100644 index 00000000..33342e41 --- /dev/null +++ b/receivepack/service/command.go @@ -0,0 +1,32 @@ +package service + +import "codeberg.org/lindenii/furgit/objectid" + +// Command is one protocol-independent requested ref update. +type Command struct { + OldID objectid.ObjectID + NewID objectid.ObjectID + Name string +} + +func fillCommandErrors(result *Result, commands []Command, errText string) { + for _, command := range commands { + result.Commands = append(result.Commands, CommandResult{ + Name: command.Name, + Error: errText, + RefName: command.Name, + OldID: objectIDPointer(command.OldID), + NewID: objectIDPointer(command.NewID), + }) + } +} + +func isDelete(command Command) bool { + return command.NewID == objectid.Zero(command.NewID.Algorithm()) +} + +func objectIDPointer(id objectid.ObjectID) *objectid.ObjectID { + out := id + + return &out +} diff --git a/receivepack/service/command_result.go b/receivepack/service/command_result.go new file mode 100644 index 00000000..18e39acc --- /dev/null +++ b/receivepack/service/command_result.go @@ -0,0 +1,13 @@ +package service + +import "codeberg.org/lindenii/furgit/objectid" + +// CommandResult is one per-command execution result. +type CommandResult struct { + Name string + Error string + RefName string + OldID *objectid.ObjectID + NewID *objectid.ObjectID + ForcedUpdate bool +} diff --git a/receivepack/service/doc.go b/receivepack/service/doc.go new file mode 100644 index 00000000..2bb15a38 --- /dev/null +++ b/receivepack/service/doc.go @@ -0,0 +1,2 @@ +// Package service implements the protocol-independent receive-pack service. +package service diff --git a/receivepack/service/execute.go b/receivepack/service/execute.go new file mode 100644 index 00000000..14468799 --- /dev/null +++ b/receivepack/service/execute.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "os" +) + +// Execute validates one receive-pack request, optionally ingests its pack into +// quarantine, runs the optional hook, and applies allowed ref updates. +func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) { + result := &Result{ + Commands: make([]CommandResult, 0, len(req.Commands)), + } + + var ( + quarantineName string + quarantineRoot *os.Root + err error + ) + + quarantineName, quarantineRoot, ok := service.ingestQuarantine(result, req.Commands, req) + if !ok { + return result, nil + } + + if quarantineRoot != nil { + defer func() { + _ = quarantineRoot.Close() + _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) + }() + } + + for _, command := range req.Commands { + result.Planned = append(result.Planned, PlannedUpdate{ + Name: command.Name, + OldID: command.OldID, + NewID: command.NewID, + Delete: isDelete(command), + }) + } + + if len(req.Commands) == 0 { + return result, nil + } + + allowedCommands, allowedIndices, rejected, ok, errText := service.runHook( + ctx, + req, + req.Commands, + quarantineName, + ) + if !ok { + fillCommandErrors(result, req.Commands, errText) + + return result, nil + } + + 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. + err = service.promoteQuarantine(quarantineName, quarantineRoot) + if err != nil { + result.UnpackError = err.Error() + fillCommandErrors(result, req.Commands, err.Error()) + + return result, nil + } + } + + if req.Atomic { + 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 + } + + 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/service/hook.go b/receivepack/service/hook.go new file mode 100644 index 00000000..748a00b9 --- /dev/null +++ b/receivepack/service/hook.go @@ -0,0 +1,37 @@ +package service + +import ( + "context" + "io" + + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objectstore" + "codeberg.org/lindenii/furgit/refstore" +) + +type HookIO struct { + Progress io.Writer + Error io.Writer +} + +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 + IO HookIO +} + +type Hook func(context.Context, HookRequest) ([]UpdateDecision, error) diff --git a/receivepack/service/hook_apply.go b/receivepack/service/hook_apply.go new file mode 100644 index 00000000..5bd8f596 --- /dev/null +++ b/receivepack/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/service/ingest_quarantine.go b/receivepack/service/ingest_quarantine.go new file mode 100644 index 00000000..bf918c6d --- /dev/null +++ b/receivepack/service/ingest_quarantine.go @@ -0,0 +1,75 @@ +package service + +import ( + "os" + + "codeberg.org/lindenii/furgit/format/pack/ingest" +) + +func (service *Service) ingestQuarantine( + result *Result, + commands []Command, + req *Request, +) (string, *os.Root, bool) { + if !req.PackExpected { + return "", nil, true + } + + if req.Pack == nil { + result.UnpackError = "missing pack stream" + fillCommandErrors(result, commands, "missing pack stream") + + return "", nil, false + } + + if service.opts.ObjectsRoot == nil { + result.UnpackError = "objects root not configured" + fillCommandErrors(result, commands, "objects root not configured") + + return "", nil, false + } + + quarantineName, quarantineRoot, err := service.createQuarantineRoot() + if err != nil { + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + return "", nil, false + } + + quarantinePackRoot, err := service.openQuarantinePackRoot(quarantineRoot) + if err != nil { + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + _ = quarantineRoot.Close() + _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) + + return "", nil, false + } + + ingested, err := ingest.Ingest( + req.Pack, + quarantinePackRoot, + service.opts.Algorithm, + true, + true, + service.opts.ExistingObjects, + ) + + _ = quarantinePackRoot.Close() + + if err != nil { + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + _ = quarantineRoot.Close() + _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) + + return "", nil, false + } + + result.Ingest = &ingested + + return quarantineName, quarantineRoot, true +} diff --git a/receivepack/service/options.go b/receivepack/service/options.go new file mode 100644 index 00000000..b8dda2f7 --- /dev/null +++ b/receivepack/service/options.go @@ -0,0 +1,26 @@ +package service + +import ( + "io/fs" + "os" + + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objectstore" + "codeberg.org/lindenii/furgit/refstore" +) + +type PromotedObjectPermissions struct { + DirMode fs.FileMode + FileMode fs.FileMode +} + +// 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 + Hook Hook + HookIO HookIO +} diff --git a/receivepack/service/quarantine.go b/receivepack/service/quarantine.go new file mode 100644 index 00000000..97a85959 --- /dev/null +++ b/receivepack/service/quarantine.go @@ -0,0 +1,269 @@ +package service + +import ( + "bytes" + "crypto/rand" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "slices" +) + +// createQuarantineRoot creates one per-push quarantine directory beneath the +// permanent objects root. +func (service *Service) createQuarantineRoot() (string, *os.Root, error) { + name := "tmp_objdir-incoming-" + rand.Text() + + err := service.opts.ObjectsRoot.Mkdir(name, 0o700) + if err != nil { + return "", nil, err + } + + root, err := service.opts.ObjectsRoot.OpenRoot(name) + if err != nil { + _ = service.opts.ObjectsRoot.RemoveAll(name) + + return "", nil, err + } + + return name, root, nil +} + +func (service *Service) openQuarantinePackRoot(quarantineRoot *os.Root) (*os.Root, error) { + err := quarantineRoot.Mkdir("pack", 0o755) + if err != nil && !os.IsExist(err) { + return nil, err + } + + return quarantineRoot.OpenRoot("pack") +} + +func (service *Service) promoteQuarantine(quarantineName string, quarantineRoot *os.Root) error { + if quarantineName == "" || quarantineRoot == nil { + return nil + } + + return service.promoteQuarantineDir(quarantineName, quarantineRoot, ".") +} + +func (service *Service) promoteQuarantineDir(quarantineName string, quarantineRoot *os.Root, rel string) error { + entries, err := fs.ReadDir(quarantineRoot.FS(), rel) + if err != nil && !os.IsNotExist(err) { + return err + } + + slices.SortFunc(entries, func(left, right fs.DirEntry) int { + return packCopyPriority(left.Name()) - packCopyPriority(right.Name()) + }) + + for _, entry := range entries { + childRel := entry.Name() + if rel != "." { + childRel = path.Join(rel, entry.Name()) + } + + if entry.IsDir() { + err = service.opts.ObjectsRoot.Mkdir(childRel, 0o755) + if err != nil && !os.IsExist(err) { + return err + } + + err = service.applyPromotedDirectoryPermissions(childRel) + if err != nil { + return err + } + + err = service.promoteQuarantineDir(quarantineName, quarantineRoot, childRel) + if err != nil { + return err + } + + continue + } + + err = finalizeQuarantineFile( + service.opts.ObjectsRoot, + path.Join(quarantineName, childRel), + childRel, + isLooseObjectShardPath(rel), + service.opts.PromotedObjectPermissions, + ) + if err == nil { + continue + } + + return err + } + + return nil +} + +func packCopyPriority(name string) int { + if !pathHasPackPrefix(name) { + return 0 + } + + switch { + case path.Ext(name) == ".keep": + return 1 + case path.Ext(name) == ".pack": + return 2 + case path.Ext(name) == ".rev": + return 3 + case path.Ext(name) == ".idx": + return 4 + default: + return 5 + } +} + +func pathHasPackPrefix(name string) bool { + return len(name) >= 4 && name[:4] == "pack" +} + +func isLooseObjectShardPath(rel string) bool { + return len(rel) == 2 && isHex(rel[0]) && isHex(rel[1]) +} + +func isHex(ch byte) bool { + return ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'f') || ('A' <= ch && ch <= 'F') +} + +func (service *Service) applyPromotedDirectoryPermissions(name string) error { + if service.opts.PromotedObjectPermissions == nil { + return nil + } + + return service.opts.ObjectsRoot.Chmod(name, service.opts.PromotedObjectPermissions.DirMode) +} + +func applyPromotedFilePermissions( + root *os.Root, + name string, + perms *PromotedObjectPermissions, +) error { + if perms == nil { + return nil + } + + return root.Chmod(name, perms.FileMode) +} + +func finalizeQuarantineFile( + root *os.Root, + src, dst string, + skipCollisionCheck bool, + perms *PromotedObjectPermissions, +) error { + const maxVanishedRetries = 5 + + for retries := 0; ; retries++ { + err := root.Link(src, dst) + switch { + case err == nil: + _ = root.Remove(src) + + return applyPromotedFilePermissions(root, dst, perms) + case !errors.Is(err, fs.ErrExist): + _, statErr := root.Stat(dst) + switch { + case statErr == nil: + err = fs.ErrExist + case errors.Is(statErr, fs.ErrNotExist): + renameErr := root.Rename(src, dst) + if renameErr == nil { + return applyPromotedFilePermissions(root, dst, perms) + } + + err = renameErr + default: + _ = root.Remove(src) + + return statErr + } + } + + if !errors.Is(err, fs.ErrExist) { + _ = root.Remove(src) + + return fmt.Errorf("promote quarantine %q -> %q: %w", src, dst, err) + } + + if skipCollisionCheck { + _ = root.Remove(src) + + return applyPromotedFilePermissions(root, dst, perms) + } + + equal, vanished, cmpErr := compareRootFiles(root, src, dst) + if vanished { + if retries >= maxVanishedRetries { + return fmt.Errorf("promote quarantine %q -> %q: destination repeatedly vanished", src, dst) + } + + continue + } + + if cmpErr != nil { + return cmpErr + } + + if !equal { + return fmt.Errorf("promote quarantine %q -> %q: files differ in contents", src, dst) + } + + _ = root.Remove(src) + + return applyPromotedFilePermissions(root, dst, perms) + } +} + +func compareRootFiles(root *os.Root, left, right string) (equal bool, vanished bool, err error) { + leftFile, err := root.Open(left) + if err != nil { + return false, false, err + } + + defer func() { + _ = leftFile.Close() + }() + + rightFile, err := root.Open(right) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, true, nil + } + + return false, false, err + } + + defer func() { + _ = rightFile.Close() + }() + + var leftBuf, rightBuf [4096]byte + + for { + leftN, leftErr := leftFile.Read(leftBuf[:]) + rightN, rightErr := rightFile.Read(rightBuf[:]) + + if leftErr != nil && !errors.Is(leftErr, io.EOF) { + return false, false, leftErr + } + + if rightErr != nil && !errors.Is(rightErr, io.EOF) { + return false, false, rightErr + } + + if leftN != rightN || !bytes.Equal(leftBuf[:leftN], rightBuf[:rightN]) { + return false, false, nil + } + + if leftErr != nil || rightErr != nil { + return true, false, nil + } + } +} diff --git a/receivepack/service/quarantine_objects.go b/receivepack/service/quarantine_objects.go new file mode 100644 index 00000000..69e07a1d --- /dev/null +++ b/receivepack/service/quarantine_objects.go @@ -0,0 +1,50 @@ +package service + +import ( + "os" + + "codeberg.org/lindenii/furgit/objectstore" + "codeberg.org/lindenii/furgit/objectstore/loose" + "codeberg.org/lindenii/furgit/objectstore/memory" + objectmix "codeberg.org/lindenii/furgit/objectstore/mix" + "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/service/quarantine_test.go b/receivepack/service/quarantine_test.go new file mode 100644 index 00000000..0bab3728 --- /dev/null +++ b/receivepack/service/quarantine_test.go @@ -0,0 +1,184 @@ +package service //nolint:testpackage + +// because we need access to quarantine internals + +import ( + "os" + "path" + "testing" + + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objectstore/memory" +) + +type quarantineFixture struct { + svc *Service + objectsRoot *os.Root + quarantineName string + quarantineRoot *os.Root +} + +func newQuarantineFixture(tb testing.TB, opts Options) *quarantineFixture { + tb.Helper() + + objectsRoot, err := os.OpenRoot(tb.TempDir()) + if err != nil { + tb.Fatalf("os.OpenRoot: %v", err) + } + + tb.Cleanup(func() { + _ = objectsRoot.Close() + }) + + opts.Algorithm = objectid.AlgorithmSHA1 + opts.ExistingObjects = memory.New(objectid.AlgorithmSHA1) + opts.ObjectsRoot = objectsRoot + + svc := New(opts) + + quarantineName, quarantineRoot, err := svc.createQuarantineRoot() + if err != nil { + tb.Fatalf("createQuarantineRoot: %v", err) + } + + tb.Cleanup(func() { + _ = quarantineRoot.Close() + _ = objectsRoot.RemoveAll(quarantineName) + }) + + return &quarantineFixture{ + svc: svc, + objectsRoot: objectsRoot, + quarantineName: quarantineName, + quarantineRoot: quarantineRoot, + } +} + +func writeMatchingPromotedFile( + tb testing.TB, + quarantineRoot, objectsRoot *os.Root, + dir, name, payload string, +) { + tb.Helper() + + err := quarantineRoot.Mkdir(dir, 0o755) + if err != nil { + tb.Fatalf("Mkdir(%s): %v", dir, err) + } + + err = objectsRoot.Mkdir(dir, 0o755) + if err != nil { + tb.Fatalf("Mkdir(dst %s): %v", dir, err) + } + + rel := path.Join(dir, name) + + err = quarantineRoot.WriteFile(rel, []byte(payload), 0o644) + if err != nil { + tb.Fatalf("WriteFile(quarantine %s): %v", rel, err) + } + + err = objectsRoot.WriteFile(rel, []byte(payload), 0o644) + if err != nil { + tb.Fatalf("WriteFile(permanent %s): %v", rel, err) + } +} + +func TestPromoteQuarantineAppliesConfiguredPermissions(t *testing.T) { + t.Parallel() + + fx := newQuarantineFixture(t, Options{ + PromotedObjectPermissions: &PromotedObjectPermissions{ + DirMode: 0o751, + FileMode: 0o640, + }, + }) + + err := fx.quarantineRoot.Mkdir("ab", 0o700) + if err != nil { + t.Fatalf("Mkdir(ab): %v", err) + } + + err = fx.quarantineRoot.WriteFile(path.Join("ab", "cdef"), []byte("payload"), 0o600) + if err != nil { + t.Fatalf("WriteFile(quarantine loose): %v", err) + } + + err = fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) + if err != nil { + t.Fatalf("promoteQuarantine: %v", err) + } + + dirInfo, err := fx.objectsRoot.Stat("ab") + if err != nil { + t.Fatalf("Stat(ab): %v", err) + } + + if got := dirInfo.Mode().Perm(); got != 0o751 { + t.Fatalf("dir mode = %o, want 751", got) + } + + fileInfo, err := fx.objectsRoot.Stat(path.Join("ab", "cdef")) + if err != nil { + t.Fatalf("Stat(ab/cdef): %v", err) + } + + if got := fileInfo.Mode().Perm(); got != 0o640 { + t.Fatalf("file mode = %o, want 640", got) + } +} + +func TestPromoteQuarantineTreatsExistingLooseObjectAsSuccess(t *testing.T) { + t.Parallel() + + fx := newQuarantineFixture(t, Options{}) + writeMatchingPromotedFile(t, fx.quarantineRoot, fx.objectsRoot, "ab", "cdef", "same object bytes") + + err := fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) + if err != nil { + t.Fatalf("promoteQuarantine: %v", err) + } +} + +func TestPromoteQuarantineRejectsDifferentExistingPackFile(t *testing.T) { + t.Parallel() + + fx := newQuarantineFixture(t, Options{}) + + err := fx.quarantineRoot.Mkdir("pack", 0o755) + if err != nil { + t.Fatalf("Mkdir(pack): %v", err) + } + + err = fx.objectsRoot.Mkdir("pack", 0o755) + if err != nil { + t.Fatalf("Mkdir(dst pack): %v", err) + } + + err = fx.quarantineRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("new bytes"), 0o644) + if err != nil { + t.Fatalf("WriteFile(quarantine pack): %v", err) + } + + err = fx.objectsRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("old bytes"), 0o644) + if err != nil { + t.Fatalf("WriteFile(permanent pack): %v", err) + } + + err = fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) + if err == nil { + t.Fatal("promoteQuarantine unexpectedly succeeded") + } +} + +func TestPromoteQuarantineAcceptsMatchingExistingPackFile(t *testing.T) { + t.Parallel() + + fx := newQuarantineFixture(t, Options{}) + writeMatchingPromotedFile(t, fx.quarantineRoot, fx.objectsRoot, "pack", "pack-a.pack", "identical pack bytes") + + err := fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot) + if err != nil { + t.Fatalf("promoteQuarantine: %v", err) + } +} diff --git a/receivepack/service/request.go b/receivepack/service/request.go new file mode 100644 index 00000000..7a0b1f33 --- /dev/null +++ b/receivepack/service/request.go @@ -0,0 +1,13 @@ +package service + +import "io" + +// Request is one protocol-independent receive-pack execution request. +type Request struct { + Commands []Command + PushOptions []string + Atomic bool + DeleteOnly bool + PackExpected bool + Pack io.Reader +} diff --git a/receivepack/service/result.go b/receivepack/service/result.go new file mode 100644 index 00000000..7db7dcb1 --- /dev/null +++ b/receivepack/service/result.go @@ -0,0 +1,14 @@ +package service + +import ( + "codeberg.org/lindenii/furgit/format/pack/ingest" +) + +// Result is one receive-pack execution result. +type Result struct { + UnpackError string + Commands []CommandResult + Ingest *ingest.Result + Planned []PlannedUpdate + Applied bool +} diff --git a/receivepack/service/run_hook.go b/receivepack/service/run_hook.go new file mode 100644 index 00000000..3c76906e --- /dev/null +++ b/receivepack/service/run_hook.go @@ -0,0 +1,74 @@ +package service + +import "context" + +func (service *Service) runHook( + ctx context.Context, + req *Request, + commands []Command, + quarantineName string, +) ( + allowedCommands []Command, + allowedIndices []int, + rejected map[int]string, + ok bool, + errText string, +) { + allowedCommands = append([]Command(nil), commands...) + + allowedIndices = make([]int, 0, len(commands)) + for index := range commands { + allowedIndices = append(allowedIndices, index) + } + + rejected = make(map[int]string) + if service.opts.Hook == nil { + return allowedCommands, allowedIndices, rejected, true, "" + } + + quarantinedObjects, err := service.openQuarantinedObjects(quarantineName) + if err != nil { + return nil, nil, nil, false, err.Error() + } + + defer func() { + _ = quarantinedObjects.Close() + }() + + decisions, err := service.opts.Hook(ctx, HookRequest{ + Refs: service.opts.Refs, + ExistingObjects: service.opts.ExistingObjects, + QuarantinedObjects: quarantinedObjects, + Updates: buildHookUpdates(commands), + PushOptions: append([]string(nil), req.PushOptions...), + IO: service.opts.HookIO, + }) + if err != nil { + return nil, nil, nil, false, err.Error() + } + + if len(decisions) != len(commands) { + return nil, nil, nil, false, "hook returned wrong number of update decisions" + } + + allowedCommands = allowedCommands[:0] + allowedIndices = allowedIndices[:0] + + for index, decision := range decisions { + if decision.Accept { + allowedCommands = append(allowedCommands, commands[index]) + allowedIndices = append(allowedIndices, index) + + continue + } + + message := decision.Message + if message == "" { + message = "rejected by hook" + } + + rejected[index] = message + } + + return allowedCommands, allowedIndices, rejected, true, "" +} diff --git a/receivepack/service/service.go b/receivepack/service/service.go new file mode 100644 index 00000000..d204e9aa --- /dev/null +++ b/receivepack/service/service.go @@ -0,0 +1,11 @@ +package service + +// Service executes protocol-independent receive-pack requests. +type Service struct { + opts Options +} + +// New creates one receive-pack service. +func New(opts Options) *Service { + return &Service{opts: opts} +} diff --git a/receivepack/service/service_test.go b/receivepack/service/service_test.go new file mode 100644 index 00000000..a29e71de --- /dev/null +++ b/receivepack/service/service_test.go @@ -0,0 +1,99 @@ +package service_test + +import ( + "context" + "io/fs" + "os" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objectstore/memory" + "codeberg.org/lindenii/furgit/receivepack/internal/service" +) + +func TestExecutePackExpectedWithoutObjectsRoot(t *testing.T) { + t.Parallel() + + //nolint:thelper + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + t.Parallel() + + store := memory.New(algo) + svc := service.New(service.Options{ + Algorithm: algo, + ExistingObjects: store, + }) + + result, err := svc.Execute(context.Background(), &service.Request{ + Commands: []service.Command{{ + Name: "refs/heads/main", + OldID: objectid.Zero(algo), + NewID: objectid.Zero(algo), + }}, + PackExpected: true, + Pack: strings.NewReader("not a pack"), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + if result.UnpackError != "objects root not configured" { + t.Fatalf("unexpected unpack error %q", result.UnpackError) + } + }) +} + +func TestExecuteRemovesDerivedQuarantineAfterIngestFailure(t *testing.T) { + t.Parallel() + + //nolint:thelper + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { + t.Parallel() + + store := memory.New(algo) + objectsDir := t.TempDir() + + objectsRoot, err := os.OpenRoot(objectsDir) + if err != nil { + t.Fatalf("os.OpenRoot: %v", err) + } + + t.Cleanup(func() { + _ = objectsRoot.Close() + }) + + svc := service.New(service.Options{ + Algorithm: algo, + ExistingObjects: store, + ObjectsRoot: objectsRoot, + }) + + result, err := svc.Execute(context.Background(), &service.Request{ + Commands: []service.Command{{ + Name: "refs/heads/main", + OldID: objectid.Zero(algo), + NewID: objectid.Zero(algo), + }}, + PackExpected: true, + Pack: strings.NewReader("not a pack"), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + if result.UnpackError == "" { + t.Fatal("Execute returned empty unpack error for invalid pack") + } + + entries, err := fs.ReadDir(objectsRoot.FS(), ".") + if err != nil { + t.Fatalf("fs.ReadDir: %v", err) + } + + if len(entries) != 0 { + t.Fatalf("objects root still has entries after failed ingest: %d", len(entries)) + } + }) +} diff --git a/receivepack/service/update.go b/receivepack/service/update.go new file mode 100644 index 00000000..c73b73a5 --- /dev/null +++ b/receivepack/service/update.go @@ -0,0 +1,12 @@ +package service + +import "codeberg.org/lindenii/furgit/objectid" + +// PlannedUpdate is one ref update that would be applied once ref writing +// exists. +type PlannedUpdate struct { + Name string + OldID objectid.ObjectID + NewID objectid.ObjectID + Delete bool +} -- cgit v1.3.1-10-gc9f91