diff options
| author | 2026-03-26 09:14:59 +0000 | |
|---|---|---|
| committer | 2026-03-26 09:14:59 +0000 | |
| commit | 3d25bda9d5da6814661828adabe8a09f9d01aefb (patch) | |
| tree | d034e28079333f85e5d7b96d921282eddd4798d6 /network | |
| parent | object/id: Empty tree (diff) | |
| signature | No signature | |
network/receivepack: Rename from receivepack
Diffstat (limited to 'network')
32 files changed, 3031 insertions, 0 deletions
diff --git a/network/receivepack/advertise.go b/network/receivepack/advertise.go new file mode 100644 index 00000000..0fa010bf --- /dev/null +++ b/network/receivepack/advertise.go @@ -0,0 +1,57 @@ +package receivepack + +import ( + "errors" + + common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server" + "codeberg.org/lindenii/furgit/ref" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +func advertisedRefs(opts Options) ([]common.AdvertisedRef, error) { + listed, err := opts.Refs.List("") + if err != nil { + return nil, err + } + + return buildAdvertisedRefs(opts, listed) +} + +func buildAdvertisedRefs(opts Options, listed []ref.Ref) ([]common.AdvertisedRef, error) { + refs := make([]common.AdvertisedRef, 0, len(listed)) + for _, entry := range listed { + switch resolved := entry.(type) { + case ref.Detached: + advertised := common.AdvertisedRef{ + Name: resolved.Name(), + ID: resolved.ID, + } + + if resolved.Peeled != nil { + advertised.Peeled = resolved.Peeled + } + + refs = append(refs, advertised) + case ref.Symbolic: + if resolved.Name() != "HEAD" { + continue + } + + head, err := opts.Refs.ResolveToDetached("HEAD") + if err != nil { + if errors.Is(err, refstore.ErrReferenceNotFound) { + continue + } + + return nil, err + } + + refs = append(refs, common.AdvertisedRef{ + Name: "HEAD", + ID: head.ID, + }) + } + } + + return refs, nil +} diff --git a/network/receivepack/capabilities_defaults.go b/network/receivepack/capabilities_defaults.go new file mode 100644 index 00000000..72c36c30 --- /dev/null +++ b/network/receivepack/capabilities_defaults.go @@ -0,0 +1,17 @@ +package receivepack + +import ( + "crypto/rand" +) + +func defaultAgent() string { + return "furgit" +} + +func defaultSessionID() string { + return "furgit-" + rand.Text() +} + +func defaultPushCertNonce() string { + return "furgit-" + rand.Text() +} diff --git a/network/receivepack/commands.go b/network/receivepack/commands.go new file mode 100644 index 00000000..a9edec1a --- /dev/null +++ b/network/receivepack/commands.go @@ -0,0 +1,19 @@ +package receivepack + +import ( + protoreceive "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack" + "codeberg.org/lindenii/furgit/network/receivepack/service" +) + +func translateCommands(commands []protoreceive.Command) []service.Command { + out := make([]service.Command, 0, len(commands)) + for _, command := range commands { + out = append(out, service.Command{ + OldID: command.OldID, + NewID: command.NewID, + Name: command.Name, + }) + } + + return out +} diff --git a/network/receivepack/doc.go b/network/receivepack/doc.go new file mode 100644 index 00000000..b63f49d5 --- /dev/null +++ b/network/receivepack/doc.go @@ -0,0 +1,3 @@ +// Package receivepack provides the application-facing server-side push entry +// point. +package receivepack diff --git a/network/receivepack/errors.go b/network/receivepack/errors.go new file mode 100644 index 00000000..18e7a135 --- /dev/null +++ b/network/receivepack/errors.go @@ -0,0 +1,15 @@ +package receivepack + +import "errors" + +var ( + // ErrMissingAlgorithm reports one missing repository hash algorithm. + ErrMissingAlgorithm = errors.New("receivepack: missing object id algorithm") + // ErrMissingRefs reports one missing reference store dependency. + ErrMissingRefs = errors.New("receivepack: missing refs store") + // ErrMissingObjects reports one missing object store dependency. + ErrMissingObjects = errors.New("receivepack: missing objects store") + // ErrUnsupportedProtocol reports one unsupported requested Git protocol + // version. + ErrUnsupportedProtocol = errors.New("receivepack: unsupported protocol version") +) diff --git a/network/receivepack/hook.go b/network/receivepack/hook.go new file mode 100644 index 00000000..81286bea --- /dev/null +++ b/network/receivepack/hook.go @@ -0,0 +1,93 @@ +package receivepack + +import ( + "context" + "io" + + objectid "codeberg.org/lindenii/furgit/object/id" + objectstorer "codeberg.org/lindenii/furgit/object/storer" + "codeberg.org/lindenii/furgit/network/receivepack/service" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +type HookIO struct { + Progress io.Writer + Error io.Writer +} + +// 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. +// +// Refs, ExistingObjects, and QuarantinedObjects are borrowed and are only +// valid for the duration of the hook call. +type HookRequest struct { + Refs refstore.ReadingStore + ExistingObjects objectstorer.Store + QuarantinedObjects objectstorer.Store + Updates []RefUpdate + PushOptions []string + IO HookIO +} + +// 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. Hook borrows the data and stores in HookRequest only for +// the duration of the call. +type Hook func(context.Context, HookRequest) ([]UpdateDecision, error) + +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...), + IO: HookIO{ + Progress: req.IO.Progress, + Error: req.IO.Error, + }, + }) + 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 + } +} diff --git a/network/receivepack/hooks/chain.go b/network/receivepack/hooks/chain.go new file mode 100644 index 00000000..f98c06f8 --- /dev/null +++ b/network/receivepack/hooks/chain.go @@ -0,0 +1,51 @@ +package hooks + +import ( + "context" + "fmt" + + receivepack "codeberg.org/lindenii/furgit/network/receivepack" +) + +// Chain combines hooks by running them in order and intersecting their +// decisions. The first rejecting message for each update is preserved. +func Chain(hooks ...receivepack.Hook) receivepack.Hook { + return func( + ctx context.Context, + req receivepack.HookRequest, + ) ([]receivepack.UpdateDecision, error) { + decisions := make([]receivepack.UpdateDecision, len(req.Updates)) + for i := range decisions { + decisions[i].Accept = true + } + + for _, hook := range hooks { + if hook == nil { + continue + } + + hookDecisions, err := hook(ctx, req) + if err != nil { + return nil, err + } + + if len(hookDecisions) != len(req.Updates) { + return nil, fmt.Errorf("hook returned %d decisions for %d updates", len(hookDecisions), len(req.Updates)) + } + + for i, decision := range hookDecisions { + if decision.Accept { + continue + } + + if decisions[i].Accept { + decisions[i].Message = decision.Message + } + + decisions[i].Accept = false + } + } + + return decisions, nil + } +} diff --git a/network/receivepack/hooks/doc.go b/network/receivepack/hooks/doc.go new file mode 100644 index 00000000..bef2baf9 --- /dev/null +++ b/network/receivepack/hooks/doc.go @@ -0,0 +1,2 @@ +// Package hooks provides a few pre-defined hooks that callers might find useful. +package hooks diff --git a/network/receivepack/hooks/reject_force_push.go b/network/receivepack/hooks/reject_force_push.go new file mode 100644 index 00000000..79714c8b --- /dev/null +++ b/network/receivepack/hooks/reject_force_push.go @@ -0,0 +1,64 @@ +package hooks + +import ( + "context" + "errors" + "fmt" + + "codeberg.org/lindenii/furgit/commitquery" + objectid "codeberg.org/lindenii/furgit/object/id" + objectmix "codeberg.org/lindenii/furgit/object/storer/mix" + receivepack "codeberg.org/lindenii/furgit/network/receivepack" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +// RejectForcePush rejects updates whose new value is not a fast-forward of the +// currently resolved reference. +func RejectForcePush() receivepack.Hook { + return func( + ctx context.Context, + req receivepack.HookRequest, + ) ([]receivepack.UpdateDecision, error) { + _ = ctx + + objects := objectmix.New(req.QuarantinedObjects, req.ExistingObjects) + + decisions := make([]receivepack.UpdateDecision, len(req.Updates)) + for i := range decisions { + decisions[i].Accept = true + } + + for i, update := range req.Updates { + if update.OldID == objectid.Zero(update.OldID.Algorithm()) || update.NewID == objectid.Zero(update.NewID.Algorithm()) { + continue + } + + current, err := req.Refs.ResolveToDetached(update.Name) + switch { + case err == nil: + case errors.Is(err, refstore.ErrReferenceNotFound): + continue + default: + return nil, fmt.Errorf("resolve %s: %w", update.Name, err) + } + + if current.ID == update.NewID { + continue + } + + ok, err := commitquery.New(objects, nil).IsAncestor(current.ID, update.NewID) + if err != nil { + return nil, fmt.Errorf("check fast-forward %s: %w", update.Name, err) + } + + if !ok { + decisions[i] = receivepack.UpdateDecision{ + Accept: false, + Message: "non-fast-forward", + } + } + } + + return decisions, nil + } +} diff --git a/network/receivepack/int_test.go b/network/receivepack/int_test.go new file mode 100644 index 00000000..d17e6cd4 --- /dev/null +++ b/network/receivepack/int_test.go @@ -0,0 +1,1047 @@ +package receivepack_test + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/network/protocol/pktline" + "codeberg.org/lindenii/furgit/network/protocol/sideband64k" + objectid "codeberg.org/lindenii/furgit/object/id" + receivepack "codeberg.org/lindenii/furgit/network/receivepack" + receivepackhooks "codeberg.org/lindenii/furgit/network/receivepack/hooks" +) + +func TestReceivePackDeleteOnlyAtomicDeleteSucceeds(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) + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + GitProtocol: "", + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "ok refs/heads/main\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err == nil { + t.Fatal("refs/heads/main still exists after delete push") + } + }) +} + +func TestReceivePackDeleteOnlyNonAtomicAppliesIndependentDeletes(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") + _, _, staleID := testRepo.MakeCommit(t, "stale") + 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( + staleID.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{ + GitProtocol: "", + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "ng refs/heads/main ") || !strings.Contains(got, "ok refs/heads/topic\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(main): %v", err) + } + + _, err = repo.Refs().Resolve("refs/heads/topic") + if err == nil { + t.Fatal("refs/heads/topic still exists after successful delete") + } + }) +} + +func TestReceivePackDeleteOnlyAtomicFailureLeavesAllRefsUntouched(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") + _, _, staleID := testRepo.MakeCommit(t, "stale") + 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( + staleID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic 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{ + GitProtocol: "", + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "ng refs/heads/main ") || !strings.Contains(got, "ng refs/heads/topic ") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(main): %v", err) + } + + _, err = repo.Refs().Resolve("refs/heads/topic") + if err != nil { + t.Fatalf("Resolve(topic): %v", err) + } + }) +} + +func TestReceivePackAdvertisesResolvedHEAD(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.SymbolicRef(t, "HEAD", "refs/heads/main") + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + + want := commitID.String() + " HEAD" + if !strings.Contains(got, want) { + t.Fatalf("HEAD advertisement missing %q in %q", want, got) + } + }) +} + +func TestReceivePackVersion2FallsBackToV0(t *testing.T) { + t.Parallel() + + testReceivePackProtocolFallback(t, "version=2") +} + +func TestReceivePackHighestRequestedVersionFallsBackToV0ForV2(t *testing.T) { + t.Parallel() + + testReceivePackProtocolFallback(t, "version=1:version=2") +} + +func TestReceivePackWithoutReportStatusWritesNoStatusPayload(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) + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00delete-refs atomic object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if strings.Contains(got, "unpack ") || strings.Contains(got, "ng refs/heads/main ") || strings.Contains(got, "ok refs/heads/main\n") { + t.Fatalf("unexpected status payload %q", got) + } + }) +} + +func testReceivePackProtocolFallback(t *testing.T, gitProtocol string) { + t.Helper() + + //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) + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + GitProtocol: gitProtocol, + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + if strings.HasPrefix(output.String(), pktlineData("version 1\n")) { + t.Fatalf("receive-pack output started with protocol v1 preface for %q: %q", gitProtocol, output.String()) + } + }) +} + +func TestReceivePackPackRequestWithoutObjectsRootReportsNotConfigured(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) + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + commitID.String() + " " + commitID.String() + " refs/heads/main\x00report-status object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "unpack objects root not configured\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + }) +} + +func TestReceivePackPackCreatePromotesObjectsAndUpdatesRef(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 + ) + + 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, + }, + ) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "unpack ok\n") || !strings.Contains(got, "ok refs/heads/main\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + reopened := receiver.OpenRepository(t) + + resolved, err := reopened.Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != commitID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, commitID) + } + + if gotType := receiver.Run(t, "cat-file", "-t", commitID.String()); gotType != "commit" { + t.Fatalf("cat-file -t = %q, want commit", gotType) + } + + packs := receiver.Run(t, "count-objects", "-v") + if !strings.Contains(packs, "packs: 1") { + t.Fatalf("count-objects output missing promoted pack: %q", packs) + } + }) +} + +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) + } + + _, _, err := req.ExistingObjects.ReadHeader(commitID) + if err == nil { + t.Fatalf("existing objects unexpectedly contained quarantined commit %s", commitID) + } + + _, _, err = req.QuarantinedObjects.ReadHeader(commitID) + if 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) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if 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) + } + + _, err = repo.Refs().Resolve("refs/heads/main") + if err != nil { + t.Fatalf("Resolve(main): %v", err) + } + + _, err = repo.Refs().Resolve("refs/heads/topic") + if err == nil { + t.Fatal("refs/heads/topic still exists after successful delete") + } + }) +} + +func TestReceivePackHookProgressUsesSideBand64K(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) + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status side-band-64k atomic delete-refs object-format=" + algo.String() + "\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) { + _, err := io.WriteString(req.IO.Progress, "hook says hello\n") + if err != nil { + return nil, err + } + + return []receivepack.UpdateDecision{{Accept: true}}, nil + }, + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + _, sidebandWire, ok := strings.Cut(output.String(), "0000") + if !ok { + t.Fatalf("output missing advertisement flush: %q", output.String()) + } + + dec := sideband64k.NewDecoder(strings.NewReader(sidebandWire), sideband64k.ReadOptions{}) + + sawHookProgress := false + + var frame sideband64k.Frame + + for { + var err error + + frame, err = dec.ReadFrame() + if err != nil { + t.Fatalf("ReadFrame: %v", err) + } + + if frame.Type == sideband64k.FrameProgress && string(frame.Payload) == "hook says hello\n" { + sawHookProgress = true + } + + if frame.Type == sideband64k.FrameData { + break + } + } + + if !sawHookProgress { + t.Fatal("missing hook progress frame") + } + + statusDec := pktline.NewDecoder(strings.NewReader(string(frame.Payload)), pktline.ReadOptions{}) + + statusFrame, err := statusDec.ReadFrame() + if err != nil { + t.Fatalf("ReadFrame(status unpack): %v", err) + } + + if statusFrame.Type != pktline.PacketData || string(statusFrame.Payload) != "unpack ok\n" { + t.Fatalf("status frame = %#v", statusFrame) + } + }) +} + +func TestReceivePackPredefinedRejectForcePushHookRejectsNonFastForward(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, Bare: true}) + _, treeID := testRepo.MakeSingleFileTree(t, "base.txt", []byte("base\n")) + baseID := testRepo.CommitTree(t, treeID, "base") + currentID := testRepo.CommitTree(t, treeID, "current", baseID) + forcedID := testRepo.CommitTree(t, treeID, "forced", baseID) + testRepo.UpdateRef(t, "refs/heads/main", currentID) + + repo := testRepo.OpenRepository(t) + objectsRoot := testRepo.OpenObjectsRoot(t) + packStream := testRepo.PackObjectsReader(t, []string{forcedID.String(), "^" + currentID.String()}, false) + t.Cleanup(func() { + _ = packStream.Close() + }) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + currentID.String() + " " + forcedID.String() + " refs/heads/main\x00report-status 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: receivepackhooks.RejectForcePush(), + }, + ) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "ng refs/heads/main non-fast-forward\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + + resolved, err := repo.Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != currentID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID) + } + }) +} + +func TestReceivePackReportStatusV2IncludesRefDetails(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) + + repo := testRepo.OpenRepository(t) + + var ( + input strings.Builder + output bufferWriteFlusher + ) + + input.WriteString(pktlineData( + commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status-v2 atomic delete-refs object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }) + if err != nil { + t.Fatalf("ReceivePack: %v", err) + } + + got := output.String() + if !strings.Contains(got, "option refname refs/heads/main\n") { + t.Fatalf("missing option refname in %q", got) + } + + if !strings.Contains(got, "option old-oid "+commitID.String()+"\n") { + t.Fatalf("missing option old-oid in %q", got) + } + + if !strings.Contains(got, "option new-oid "+objectid.Zero(algo).String()+"\n") { + t.Fatalf("missing option new-oid in %q", got) + } + }) +} + +func TestReceivePackGitPushCreatesBranch(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + _, _, commitID := sender.MakeCommit(t, "pushed commit") + sender.UpdateRef(t, "refs/heads/main", commitID) + + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + repo := receiver.OpenRepository(t) + objectsRoot := receiver.OpenObjectsRoot(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + ObjectsRoot: objectsRoot, + }, + "push", "--porcelain", "fd::3,4/test", "refs/heads/main:refs/heads/main", + ) + if clientErr != nil { + t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != commitID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, commitID) + } + }) +} + +func TestReceivePackGitPushRefUpdateWithoutNewObjectsSucceeds(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + blobID, treeID := sender.MakeSingleFileTree(t, "base.txt", []byte("base\n")) + commitID := sender.CommitTree(t, treeID, "base") + sender.UpdateRef(t, "refs/heads/main", commitID) + + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + receiver.HashObject(t, "blob", sender.RunBytes(t, "cat-file", "blob", blobID.String())) + receiver.HashObject(t, "tree", sender.RunBytes(t, "cat-file", "tree", treeID.String())) + receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", commitID.String())) + receiver.UpdateRef(t, "refs/heads/main", commitID) + + repo := receiver.OpenRepository(t) + objectsRoot := receiver.OpenObjectsRoot(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + ObjectsRoot: objectsRoot, + }, + "push", "--porcelain", "fd::3,4/test", "refs/heads/main:refs/heads/topic", + ) + if clientErr != nil { + t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/topic") + if err != nil { + t.Fatalf("ResolveToDetached(topic): %v", err) + } + + if resolved.ID != commitID { + t.Fatalf("refs/heads/topic = %s, want %s", resolved.ID, commitID) + } + + 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 TestReceivePackGitPushAtomicDelete(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + _, _, commitID := receiver.MakeCommit(t, "base") + receiver.UpdateRef(t, "refs/heads/main", commitID) + + repo := receiver.OpenRepository(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + }, + "push", "--porcelain", "--atomic", "fd::3,4/test", ":refs/heads/main", + ) + if clientErr != nil { + t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + _, err := receiver.OpenRepository(t).Refs().Resolve("refs/heads/main") + if err == nil { + t.Fatal("refs/heads/main still exists after delete push") + } + }) +} + +func TestReceivePackGitPushRejectsForcedUpdateViaHook(t *testing.T) { + t.Parallel() + + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper + t.Parallel() + + sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) + blobID, treeID := sender.MakeSingleFileTree(t, "base.txt", []byte("base\n")) + baseID := sender.CommitTree(t, treeID, "base") + currentID := sender.CommitTree(t, treeID, "current", baseID) + forcedID := sender.CommitTree(t, treeID, "forced", baseID) + sender.UpdateRef(t, "refs/heads/main", forcedID) + + receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true}) + receiver.HashObject(t, "blob", sender.RunBytes(t, "cat-file", "blob", blobID.String())) + receiver.HashObject(t, "tree", sender.RunBytes(t, "cat-file", "tree", treeID.String())) + receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", baseID.String())) + receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", currentID.String())) + receiver.UpdateRef(t, "refs/heads/main", currentID) + + repo := receiver.OpenRepository(t) + objectsRoot := receiver.OpenObjectsRoot(t) + + stdout, stderr, clientErr, serverErr := runGitPushFD( + t, + sender, + receivepack.Options{ + Algorithm: algo, + Refs: repo.Refs(), + ExistingObjects: repo.Objects(), + ObjectsRoot: objectsRoot, + Hook: receivepackhooks.RejectForcePush(), + }, + "push", "--porcelain", "--force", "fd::3,4/test", "refs/heads/main:refs/heads/main", + ) + if clientErr == nil { + t.Fatalf("git push unexpectedly succeeded\nstdout=%s\nstderr=%s", stdout, stderr) + } + + if serverErr != nil { + t.Fatalf("ReceivePack: %v", serverErr) + } + + if !strings.Contains(stdout, "non-fast-forward") && !strings.Contains(stderr, "non-fast-forward") { + t.Fatalf("git push output missing non-fast-forward message\nstdout=%s\nstderr=%s", stdout, stderr) + } + + resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/main") + if err != nil { + t.Fatalf("ResolveToDetached(main): %v", err) + } + + if resolved.ID != currentID { + t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID) + } + }) +} + +type bufferWriteFlusher struct { + strings.Builder +} + +func (bufferWriteFlusher) Flush() error { + return nil +} + +func pktlineData(payload string) string { + return fmt.Sprintf("%04x%s", len(payload)+4, payload) +} + +type fileWriteFlusher struct { + *os.File +} + +func (fileWriteFlusher) Flush() error { + return nil +} + +func runGitPushFD( + tb testing.TB, + sender *testgit.TestRepo, + opts receivepack.Options, + gitArgs ...string, +) (stdout string, stderr string, clientErr error, serverErr error) { + tb.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + serverRead, clientWrite, err := os.Pipe() + if err != nil { + tb.Fatalf("os.Pipe(serverRead/clientWrite): %v", err) + } + + clientRead, serverWrite, err := os.Pipe() + if err != nil { + tb.Fatalf("os.Pipe(clientRead/serverWrite): %v", err) + } + + tb.Cleanup(func() { + _ = serverRead.Close() + _ = clientWrite.Close() + _ = clientRead.Close() + _ = serverWrite.Close() + }) + + go func() { + <-ctx.Done() + + _ = serverRead.Close() + _ = clientWrite.Close() + _ = clientRead.Close() + _ = serverWrite.Close() + }() + + serverErrCh := make(chan error, 1) + + go func() { + defer func() { + _ = serverRead.Close() + _ = serverWrite.Close() + }() + + serverErrCh <- receivepack.ReceivePack( + ctx, + fileWriteFlusher{serverWrite}, + serverRead, + opts, + ) + }() + + stdoutBytes, stderrBytes, clientErr := sender.RunWithExtraFilesEnvContextE( + tb, + ctx, + nil, + []*os.File{clientRead, clientWrite}, + gitArgs..., + ) + _ = clientRead.Close() + _ = clientWrite.Close() + + serverErr = <-serverErrCh + + if ctx.Err() != nil { + tb.Fatalf( + "git push fd:: timed out\nstdout=%s\nstderr=%s\nclientErr=%v\nserverErr=%v", + stdoutBytes, + stderrBytes, + clientErr, + serverErr, + ) + } + + return string(stdoutBytes), string(stderrBytes), clientErr, serverErr +} diff --git a/network/receivepack/options.go b/network/receivepack/options.go new file mode 100644 index 00000000..139c3839 --- /dev/null +++ b/network/receivepack/options.go @@ -0,0 +1,68 @@ +package receivepack + +import ( + "os" + + objectid "codeberg.org/lindenii/furgit/object/id" + objectstorer "codeberg.org/lindenii/furgit/object/storer" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +// Options configures one receive-pack invocation. +// +// ReceivePack borrows all configured dependencies. +// +// Refs and ExistingObjects are required and must be non-nil. +// ObjectsRoot is required if the invocation may need to ingest or promote a +// pack. +type Options struct { + // GitProtocol is the raw Git protocol version string from the transport, + // such as "version=1". + GitProtocol string + // Algorithm is the repository object ID algorithm used by the push session. + Algorithm objectid.Algorithm + // Refs is the reference store visible to the push. + Refs refstore.ReadWriteStore + // ExistingObjects is the object store visible to the push before any newly + // uploaded quarantined objects are promoted. + ExistingObjects objectstorer.Store + // ObjectsRoot is the permanent object storage root beneath which per-push + // quarantine directories are derived. + ObjectsRoot *os.Root + // PromotedObjectPermissions, when non-nil, is applied to objects and + // directories moved from quarantine into the permanent object store. + PromotedObjectPermissions *PromotedObjectPermissions + // Hook, when non-nil, runs after pack ingestion into quarantine and before + // quarantine promotion or ref updates. Hook is borrowed for the duration of + // ReceivePack. + Hook Hook + // Agent is the receive-pack agent string advertised via capability. + // + // When empty, ReceivePack derives one from build info and falls back to + // "furgit". + Agent string + // SessionID is the advertised receive-pack session-id capability value. + // + // When empty, ReceivePack generates one random value per invocation. + SessionID string + // PushCertNonce is the advertised push-cert nonce capability value. + // + // When empty, ReceivePack generates one random value per invocation. + PushCertNonce string +} + +func validateOptions(opts Options) error { + if opts.Algorithm == 0 { + return ErrMissingAlgorithm + } + + if opts.Refs == nil { + return ErrMissingRefs + } + + if opts.ExistingObjects == nil { + return ErrMissingObjects + } + + return nil +} diff --git a/network/receivepack/permissions.go b/network/receivepack/permissions.go new file mode 100644 index 00000000..1aaa5a0c --- /dev/null +++ b/network/receivepack/permissions.go @@ -0,0 +1,27 @@ +package receivepack + +import ( + "io/fs" + + "codeberg.org/lindenii/furgit/network/receivepack/service" +) + +// PromotedObjectPermissions configures the destination permissions applied to +// objects and directories promoted out of quarantine. +type PromotedObjectPermissions struct { + DirMode fs.FileMode + FileMode fs.FileMode +} + +func translatePromotedObjectPermissions( + perms *PromotedObjectPermissions, +) *service.PromotedObjectPermissions { + if perms == nil { + return nil + } + + return &service.PromotedObjectPermissions{ + DirMode: perms.DirMode, + FileMode: perms.FileMode, + } +} diff --git a/network/receivepack/receivepack.go b/network/receivepack/receivepack.go new file mode 100644 index 00000000..4ab4962f --- /dev/null +++ b/network/receivepack/receivepack.go @@ -0,0 +1,147 @@ +package receivepack + +import ( + "context" + "io" + + "codeberg.org/lindenii/furgit/network/protocol/pktline" + common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server" + protoreceive "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack" + "codeberg.org/lindenii/furgit/network/receivepack/service" +) + +// TODO: Some more designing to do. In particular, we'd like to have access to +// commit graphs and stored object abstractions and such here, especially because +// hooks might want to access full repos, but we risk creating +// circular dependencies if we import repository/ here. Might need an interface-ish +// design, but that risks being over-complicated. +// Theoretically we could also just give the hooks an os.Root but that +// feels a bit ugly. + +// ReceivePack serves one receive-pack session over r/w. +// +// ReceivePack borrows r, w, and all dependencies reachable through opts for +// the duration of the call. It does not close any of them. +func ReceivePack( + ctx context.Context, + w pktline.WriteFlusher, + r io.Reader, + opts Options, +) error { + err := validateOptions(opts) + if err != nil { + return err + } + + version := parseVersion(opts.GitProtocol) + + base := common.NewSession(r, w, common.Options{ + Version: version, + Algorithm: opts.Algorithm, + }) + + agent := opts.Agent + if agent == "" { + agent = defaultAgent() + } + + sessionID := opts.SessionID + if sessionID == "" { + sessionID = defaultSessionID() + } + + pushCertNonce := opts.PushCertNonce + if pushCertNonce == "" { + pushCertNonce = defaultPushCertNonce() + } + + protoSession := protoreceive.NewSession(base, protoreceive.Capabilities{ + ReportStatus: true, + ReportStatusV2: true, + DeleteRefs: true, + SideBand64K: true, + Quiet: true, + Atomic: true, + OfsDelta: true, + PushOptions: true, + PushCertNonce: pushCertNonce, + SessionID: sessionID, + ObjectFormat: opts.Algorithm, + Agent: agent, + }) + + refs, err := advertisedRefs(opts) + if err != nil { + return err + } + + err = protoSession.AdvertiseRefs(common.Advertisement{Refs: refs}) + if err != nil { + return err + } + + err = base.FlushIO() + if err != nil { + return err + } + + req, err := protoSession.ReadRequest() + if err != nil { + return err + } + + progressWriter := protoSession.ProgressWriter() + progressFlush := base.FlushIO + + if req.Capabilities.Quiet { + progressWriter = io.Discard + progressFlush = nil + } + + serviceReq := &service.Request{ + Commands: translateCommands(req.Commands), + PushOptions: append([]string(nil), req.PushOptions...), + Atomic: req.Capabilities.Atomic, + DeleteOnly: req.DeleteOnly, + PackExpected: req.PackExpected, + Pack: r, + } + + svc := service.New(service.Options{ + Algorithm: opts.Algorithm, + Refs: opts.Refs, + ExistingObjects: opts.ExistingObjects, + ObjectsRoot: opts.ObjectsRoot, + Progress: progressWriter, + ProgressFlush: progressFlush, + PromotedObjectPermissions: translatePromotedObjectPermissions( + opts.PromotedObjectPermissions, + ), + Hook: translateHook(opts.Hook), + HookIO: service.HookIO{ + Progress: progressWriter, + Error: protoSession.ErrorWriter(), + }, + }) + + result, err := svc.Execute(ctx, serviceReq) + if err != nil { + return err + } + + protoResult := translateResult(result) + + if req.Capabilities.ReportStatusV2 { + err = protoSession.WriteReportStatusV2(protoResult) + if err != nil { + return err + } + } else if req.Capabilities.ReportStatus { + err = protoSession.WriteReportStatus(protoResult) + if err != nil { + return err + } + } + + return base.FlushIO() +} diff --git a/network/receivepack/results.go b/network/receivepack/results.go new file mode 100644 index 00000000..d43bee73 --- /dev/null +++ b/network/receivepack/results.go @@ -0,0 +1,26 @@ +package receivepack + +import ( + protoreceive "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack" + "codeberg.org/lindenii/furgit/network/receivepack/service" +) + +func translateResult(result *service.Result) protoreceive.ReportStatusResult { + out := protoreceive.ReportStatusResult{ + UnpackError: result.UnpackError, + Commands: make([]protoreceive.CommandResult, 0, len(result.Commands)), + } + + for _, command := range result.Commands { + out.Commands = append(out.Commands, protoreceive.CommandResult{ + Name: command.Name, + Error: command.Error, + RefName: command.RefName, + OldID: command.OldID, + NewID: command.NewID, + ForcedUpdate: command.ForcedUpdate, + }) + } + + return out +} diff --git a/network/receivepack/service/apply.go b/network/receivepack/service/apply.go new file mode 100644 index 00000000..8fa500ca --- /dev/null +++ b/network/receivepack/service/apply.go @@ -0,0 +1,134 @@ +package service + +import ( + "codeberg.org/lindenii/furgit/internal/utils" + objectid "codeberg.org/lindenii/furgit/object/id" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +func (service *Service) applyAtomic(result *Result, commands []Command) error { + total := len(commands) + utils.BestEffortFprintf(service.opts.Progress, "updating refs: 0/%d\r", total) + + tx, err := service.opts.Refs.BeginTransaction() + if err != nil { + return err + } + + for i, command := range commands { + err = queueWriteTransaction(tx, command) + if err != nil { + _ = tx.Abort() + + fillCommandErrors(result, commands, err.Error()) + utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed at %d/%d.\n", i+1, total) + + return nil + } + + utils.BestEffortFprintf(service.opts.Progress, "updating refs: %d/%d\r", i+1, total) + } + + err = tx.Commit() + if err != nil { + fillCommandErrors(result, commands, err.Error()) + utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed at commit.\n") + + return nil + } + + result.Applied = true + for _, command := range commands { + result.Commands = append(result.Commands, successCommandResult(command)) + } + + utils.BestEffortFprintf(service.opts.Progress, "updating refs: done.\n") + + return nil +} + +func (service *Service) applyBatch(result *Result, commands []Command) error { + total := len(commands) + + utils.BestEffortFprintf(service.opts.Progress, "updating refs...\r") + + 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 { + utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed at apply.\n") + + return err + } + + appliedAny := false + failedCount := 0 + + for i, command := range commands { + item := successCommandResult(command) + if i < len(batchResults) && batchResults[i].Error != nil { + item.Error = batchResults[i].Error.Error() + failedCount++ + } else { + appliedAny = true + } + + result.Commands = append(result.Commands, item) + + utils.BestEffortFprintf(service.opts.Progress, "updating refs: %d/%d\r", i+1, total) + } + + result.Applied = appliedAny + + if failedCount == 0 { + utils.BestEffortFprintf(service.opts.Progress, "updating refs: done.\n") + } else { + utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed (%d/%d).\n", failedCount, total) + } + + 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/network/receivepack/service/command.go b/network/receivepack/service/command.go new file mode 100644 index 00000000..0fd8961e --- /dev/null +++ b/network/receivepack/service/command.go @@ -0,0 +1,32 @@ +package service + +import objectid "codeberg.org/lindenii/furgit/object/id" + +// 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/network/receivepack/service/command_result.go b/network/receivepack/service/command_result.go new file mode 100644 index 00000000..37549f08 --- /dev/null +++ b/network/receivepack/service/command_result.go @@ -0,0 +1,13 @@ +package service + +import objectid "codeberg.org/lindenii/furgit/object/id" + +// 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/network/receivepack/service/doc.go b/network/receivepack/service/doc.go new file mode 100644 index 00000000..37be23f4 --- /dev/null +++ b/network/receivepack/service/doc.go @@ -0,0 +1,6 @@ +// Package service implements the protocol-independent receive-pack service. +// +// A Service borrows the stores, roots, hooks, and I/O endpoints supplied in +// Options. Callers retain ownership of those dependencies and must keep them +// valid for each Execute call that uses them. +package service diff --git a/network/receivepack/service/execute.go b/network/receivepack/service/execute.go new file mode 100644 index 00000000..9f373e0d --- /dev/null +++ b/network/receivepack/service/execute.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "os" + + "codeberg.org/lindenii/furgit/internal/utils" +) + +// 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 && quarantineRoot != nil { + // Git migrates quarantined objects into permanent storage immediately + // before starting ref updates. + utils.BestEffortFprintf(service.opts.Progress, "promoting quarantine...\r") + + err = service.promoteQuarantine(quarantineName, quarantineRoot) + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "promoting quarantine: failed: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, req.Commands, err.Error()) + + return result, nil + } + + utils.BestEffortFprintf(service.opts.Progress, "promoting quarantine: done.\n") + } + + 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/network/receivepack/service/hook.go b/network/receivepack/service/hook.go new file mode 100644 index 00000000..750720dd --- /dev/null +++ b/network/receivepack/service/hook.go @@ -0,0 +1,45 @@ +package service + +import ( + "context" + "io" + + objectid "codeberg.org/lindenii/furgit/object/id" + objectstorer "codeberg.org/lindenii/furgit/object/storer" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +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 +} + +// HookRequest is the borrowed view passed to one Hook invocation. +// +// Refs, ExistingObjects, and QuarantinedObjects are borrowed and are only +// valid for the duration of the hook call. +type HookRequest struct { + Refs refstore.ReadingStore + ExistingObjects objectstorer.Store + QuarantinedObjects objectstorer.Store + Updates []RefUpdate + PushOptions []string + IO HookIO +} + +// Hook is an optional per-request validation hook. +// +// Hook borrows the data and stores in HookRequest only for the duration of the +// call. +type Hook func(context.Context, HookRequest) ([]UpdateDecision, error) diff --git a/network/receivepack/service/hook_apply.go b/network/receivepack/service/hook_apply.go new file mode 100644 index 00000000..5bd8f596 --- /dev/null +++ b/network/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/network/receivepack/service/ingest_quarantine.go b/network/receivepack/service/ingest_quarantine.go new file mode 100644 index 00000000..8e3e2455 --- /dev/null +++ b/network/receivepack/service/ingest_quarantine.go @@ -0,0 +1,144 @@ +package service + +import ( + "os" + + "codeberg.org/lindenii/furgit/internal/utils" + "codeberg.org/lindenii/furgit/packfile/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 { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: missing pack stream.\n") + + result.UnpackError = "missing pack stream" + fillCommandErrors(result, commands, "missing pack stream") + + return "", nil, false + } + + if service.opts.ObjectsRoot == nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: objects root not configured.\n") + + result.UnpackError = "objects root not configured" + fillCommandErrors(result, commands, "objects root not configured") + + return "", nil, false + } + + var err error + + err = service.opts.ExistingObjects.Refresh() + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: refresh existing objects: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + return "", nil, false + } + + pending, err := ingest.Ingest( + req.Pack, + service.opts.Algorithm, + ingest.Options{ + FixThin: true, + WriteRev: true, + Base: service.opts.ExistingObjects, + Progress: service.opts.Progress, + ProgressFlush: service.opts.ProgressFlush, + }, + ) + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + return "", nil, false + } + + if pending.Header().ObjectCount == 0 { + discarded, err := pending.Discard() + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + return "", nil, false + } + + result.Ingest = &ingest.Result{ + PackHash: discarded.PackHash, + ObjectCount: discarded.ObjectCount, + } + + utils.BestEffortFprintf( + service.opts.Progress, + "unpacking: done (%d objects, %s).\n", + discarded.ObjectCount, + discarded.PackHash, + ) + + return "", nil, true + } + + utils.BestEffortFprintf(service.opts.Progress, "creating quarantine...\r") + + quarantineName, quarantineRoot, err := service.createQuarantineRoot() + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + return "", nil, false + } + + quarantinePackRoot, err := service.openQuarantinePackRoot(quarantineRoot) + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + _ = quarantineRoot.Close() + _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) + + return "", nil, false + } + + utils.BestEffortFprintf(service.opts.Progress, "creating quarantine: done.\n") + utils.BestEffortFprintf(service.opts.Progress, "unpacking...\r") + + ingested, err := pending.Continue(quarantinePackRoot) + + _ = quarantinePackRoot.Close() + + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err) + + result.UnpackError = err.Error() + fillCommandErrors(result, commands, err.Error()) + + _ = quarantineRoot.Close() + _ = service.opts.ObjectsRoot.RemoveAll(quarantineName) + + return "", nil, false + } + + utils.BestEffortFprintf(service.opts.Progress, "unpacking: done (%d objects, %s).\n", ingested.ObjectCount, ingested.PackHash) + + result.Ingest = &ingested + + return quarantineName, quarantineRoot, true +} diff --git a/network/receivepack/service/options.go b/network/receivepack/service/options.go new file mode 100644 index 00000000..7edf4f06 --- /dev/null +++ b/network/receivepack/service/options.go @@ -0,0 +1,36 @@ +package service + +import ( + "io" + "io/fs" + "os" + + objectid "codeberg.org/lindenii/furgit/object/id" + objectstorer "codeberg.org/lindenii/furgit/object/storer" + refstore "codeberg.org/lindenii/furgit/ref/store" +) + +type PromotedObjectPermissions struct { + DirMode fs.FileMode + FileMode fs.FileMode +} + +// Options configures one protocol-independent receive-pack service. +// +// Service borrows all configured dependencies. +// +// Refs and ExistingObjects are required and must be non-nil. +// ObjectsRoot is required if Execute may need to ingest or promote a pack. +// Progress, ProgressFlush, Hook, and HookIO are optional; when provided they +// are also borrowed for the duration of Execute. +type Options struct { + Algorithm objectid.Algorithm + Refs refstore.ReadWriteStore + ExistingObjects objectstorer.Store + ObjectsRoot *os.Root + Progress io.Writer + ProgressFlush func() error + PromotedObjectPermissions *PromotedObjectPermissions + Hook Hook + HookIO HookIO +} diff --git a/network/receivepack/service/quarantine.go b/network/receivepack/service/quarantine.go new file mode 100644 index 00000000..0bd98aeb --- /dev/null +++ b/network/receivepack/service/quarantine.go @@ -0,0 +1,274 @@ +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. +// +// It returns both the quarantine directory name relative to ObjectsRoot and an +// opened root for that directory. Callers use the name for later promotion or +// removal relative to ObjectsRoot, and use the opened root for capability-based +// access within the quarantine itself. +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/network/receivepack/service/quarantine_test.go b/network/receivepack/service/quarantine_test.go new file mode 100644 index 00000000..86299be2 --- /dev/null +++ b/network/receivepack/service/quarantine_test.go @@ -0,0 +1,184 @@ +package service //nolint:testpackage + +// because we need access to quarantine internals + +import ( + "os" + "path" + "testing" + + objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/storer/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/network/receivepack/service/request.go b/network/receivepack/service/request.go new file mode 100644 index 00000000..7f9bcc2f --- /dev/null +++ b/network/receivepack/service/request.go @@ -0,0 +1,16 @@ +package service + +import "io" + +// Request is one protocol-independent receive-pack execution request. +// +// If PackExpected is true, Pack must be non-nil and remain valid until +// Execute finishes consuming it. +type Request struct { + Commands []Command + PushOptions []string + Atomic bool + DeleteOnly bool + PackExpected bool + Pack io.Reader +} diff --git a/network/receivepack/service/result.go b/network/receivepack/service/result.go new file mode 100644 index 00000000..17fc0b6b --- /dev/null +++ b/network/receivepack/service/result.go @@ -0,0 +1,14 @@ +package service + +import ( + "codeberg.org/lindenii/furgit/packfile/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/network/receivepack/service/run_hook.go b/network/receivepack/service/run_hook.go new file mode 100644 index 00000000..94467078 --- /dev/null +++ b/network/receivepack/service/run_hook.go @@ -0,0 +1,168 @@ +package service + +import ( + "context" + "os" + + "codeberg.org/lindenii/furgit/internal/utils" + objectstorer "codeberg.org/lindenii/furgit/object/storer" + "codeberg.org/lindenii/furgit/object/storer/loose" + objectmix "codeberg.org/lindenii/furgit/object/storer/mix" + "codeberg.org/lindenii/furgit/object/storer/packed" +) + +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, "" + } + + utils.BestEffortFprintf(service.opts.Progress, "running hooks...\r") + + quarantinedObjects := service.opts.ExistingObjects + + var ( + quarantineObjectsStore objectstorer.Store + quarantineLooseStore *loose.Store + quarantinePackedStore *packed.Store + quarantineLooseRoot *os.Root + quarantinePackRoot *os.Root + err error + ) + + //nolint:nestif + if quarantineName != "" { + quarantineLooseRoot, err = service.opts.ObjectsRoot.OpenRoot(quarantineName) + if err != nil { + utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err) + + return nil, nil, nil, false, err.Error() + } + + quarantineLooseStore, err = loose.New(quarantineLooseRoot, service.opts.Algorithm) + if err != nil { + _ = quarantineLooseRoot.Close() + + utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err) + + return nil, nil, nil, false, err.Error() + } + + quarantineObjectsStore = quarantineLooseStore + quarantinedObjects = quarantineLooseStore + + quarantinePackRoot, err = quarantineLooseRoot.OpenRoot("pack") + if err == nil { + var packedErr error + + quarantinePackedStore, packedErr = packed.New(quarantinePackRoot, service.opts.Algorithm, packed.Options{}) + if packedErr != nil { + _ = quarantineLooseStore.Close() + _ = quarantinePackRoot.Close() + _ = quarantineLooseRoot.Close() + + utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", packedErr) + + return nil, nil, nil, false, packedErr.Error() + } + + quarantineObjectsStore = objectmix.New(quarantineLooseStore, quarantinePackedStore) + quarantinedObjects = quarantineObjectsStore + } else if !os.IsNotExist(err) { + _ = quarantineLooseStore.Close() + _ = quarantineLooseRoot.Close() + + utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err) + + return nil, nil, nil, false, err.Error() + } + + defer func() { + if quarantineObjectsStore != nil { + _ = quarantineObjectsStore.Close() + } + + if quarantinePackedStore != nil { + _ = quarantinePackedStore.Close() + } + + if quarantineLooseStore != nil { + _ = quarantineLooseStore.Close() + } + + if quarantinePackRoot != nil { + _ = quarantinePackRoot.Close() + } + + if quarantineLooseRoot != nil { + _ = quarantineLooseRoot.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 { + utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err) + + return nil, nil, nil, false, err.Error() + } + + if len(decisions) != len(commands) { + utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: wrong decision count.\n") + + 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 + } + + utils.BestEffortFprintf( + service.opts.Progress, + "running hooks: done (%d/%d accepted).\n", + len(allowedCommands), + len(commands), + ) + + return allowedCommands, allowedIndices, rejected, true, "" +} diff --git a/network/receivepack/service/service.go b/network/receivepack/service/service.go new file mode 100644 index 00000000..a57fd354 --- /dev/null +++ b/network/receivepack/service/service.go @@ -0,0 +1,16 @@ +package service + +// Service executes protocol-independent receive-pack requests. +// +// Service borrows all dependencies supplied in Options. +type Service struct { + opts Options +} + +// New creates one receive-pack service. +// +// The returned service borrows opts and does not take ownership of any stores, +// roots, hooks, or I/O endpoints reachable through it. +func New(opts Options) *Service { + return &Service{opts: opts} +} diff --git a/network/receivepack/service/service_test.go b/network/receivepack/service/service_test.go new file mode 100644 index 00000000..e59113be --- /dev/null +++ b/network/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" + objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/storer/memory" + "codeberg.org/lindenii/furgit/network/receivepack/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/network/receivepack/service/update.go b/network/receivepack/service/update.go new file mode 100644 index 00000000..043f3d51 --- /dev/null +++ b/network/receivepack/service/update.go @@ -0,0 +1,12 @@ +package service + +import objectid "codeberg.org/lindenii/furgit/object/id" + +// 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/network/receivepack/version.go b/network/receivepack/version.go new file mode 100644 index 00000000..9a4544dc --- /dev/null +++ b/network/receivepack/version.go @@ -0,0 +1,35 @@ +package receivepack + +import ( + "strings" + + common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server" +) + +func parseVersion(gitProtocol string) common.Version { + if gitProtocol == "" { + return common.Version0 + } + + var highestRequested uint8 + + for field := range strings.SplitSeq(gitProtocol, ":") { + switch field { + case "version=0": + case "version=1": + if highestRequested < 1 { + highestRequested = 1 + } + case "version=2": + if highestRequested < 2 { + highestRequested = 2 + } + } + } + + if highestRequested == 1 { + return common.Version1 + } + + return common.Version0 +} |
