From 175c8ed3c342f34110cdca42dc4027050b39d7fb Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Sat, 7 Mar 2026 14:24:32 +0800 Subject: receivepack: Connect protocol with service --- receivepack/advertise.go | 57 +++++++++++ receivepack/doc.go | 3 + receivepack/errors.go | 15 +++ receivepack/int_test.go | 234 +++++++++++++++++++++++++++++++++++++++++++++ receivepack/options.go | 43 +++++++++ receivepack/receivepack.go | 94 ++++++++++++++++++ receivepack/translate.go | 35 +++++++ receivepack/version.go | 35 +++++++ 8 files changed, 516 insertions(+) create mode 100644 receivepack/advertise.go create mode 100644 receivepack/doc.go create mode 100644 receivepack/errors.go create mode 100644 receivepack/int_test.go create mode 100644 receivepack/options.go create mode 100644 receivepack/receivepack.go create mode 100644 receivepack/translate.go create mode 100644 receivepack/version.go diff --git a/receivepack/advertise.go b/receivepack/advertise.go new file mode 100644 index 00000000..64fb2fe7 --- /dev/null +++ b/receivepack/advertise.go @@ -0,0 +1,57 @@ +package receivepack + +import ( + "errors" + + common "codeberg.org/lindenii/furgit/protocol/v0v1/server" + "codeberg.org/lindenii/furgit/ref" + "codeberg.org/lindenii/furgit/refstore" +) + +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.ResolveFully("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/receivepack/doc.go b/receivepack/doc.go new file mode 100644 index 00000000..b63f49d5 --- /dev/null +++ b/receivepack/doc.go @@ -0,0 +1,3 @@ +// Package receivepack provides the application-facing server-side push entry +// point. +package receivepack diff --git a/receivepack/errors.go b/receivepack/errors.go new file mode 100644 index 00000000..18e7a135 --- /dev/null +++ b/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/receivepack/int_test.go b/receivepack/int_test.go new file mode 100644 index 00000000..a790741b --- /dev/null +++ b/receivepack/int_test.go @@ -0,0 +1,234 @@ +package receivepack_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/objectid" + receivepack "codeberg.org/lindenii/furgit/receivepack" +) + +// TODO: actually test with send-pack + +func TestReceivePackDeleteOnlyReportsNotImplemented(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 delete-refs object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, 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 ref updates not implemented yet\n") { + t.Fatalf("unexpected receive-pack output %q", got) + } + }) +} + +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.Builder{}, 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 object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, 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 delete-refs object-format=" + algo.String() + "\n", + )) + input.WriteString("0000") + + err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, 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.Builder{}, 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) + } + }) +} + +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) +} diff --git a/receivepack/options.go b/receivepack/options.go new file mode 100644 index 00000000..56b4a006 --- /dev/null +++ b/receivepack/options.go @@ -0,0 +1,43 @@ +package receivepack + +import ( + "os" + + "codeberg.org/lindenii/furgit/objectid" + "codeberg.org/lindenii/furgit/objectstore" + "codeberg.org/lindenii/furgit/refstore" +) + +// Options configures one receive-pack invocation. +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.ReadingStore + // ExistingObjects is the object store visible to the push before any newly + // uploaded quarantined objects are promoted. + ExistingObjects objectstore.Store + // ObjectsRoot is the permanent object storage root beneath which per-push + // quarantine directories are derived. + ObjectsRoot *os.Root + // TODO: Hook and policy callbacks. +} + +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/receivepack/receivepack.go b/receivepack/receivepack.go new file mode 100644 index 00000000..9f4a582b --- /dev/null +++ b/receivepack/receivepack.go @@ -0,0 +1,94 @@ +package receivepack + +import ( + "context" + "io" + + "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" +) + +// ReceivePack serves one receive-pack session over r/w. +func ReceivePack( + ctx context.Context, + w pktline.WriteFlusher, + e io.Writer, + r io.Reader, + opts Options, +) error { + _ = e // TODO: Use stderr/progress sink explicitly as hook/progress behavior expands. + + err := validateOptions(opts) + if err != nil { + return err + } + + version := parseVersion(opts.GitProtocol) + + base := common.NewSession(r, w, common.Options{ + Version: version, + Algorithm: opts.Algorithm, + }) + + protoSession := protoreceive.NewSession(base, protoreceive.Capabilities{ + ReportStatus: true, + ReportStatusV2: true, + DeleteRefs: true, + SideBand64K: true, + Quiet: true, + Atomic: true, + OfsDelta: true, + PushOptions: true, + ObjectFormat: opts.Algorithm, + // TODO: PushCertNonce, SessionID, Agent, whatever. + }) + + refs, err := advertisedRefs(opts) + if err != nil { + return err + } + + err = protoSession.AdvertiseRefs(common.Advertisement{Refs: refs}) + if err != nil { + return err + } + + req, err := protoSession.ReadRequest() + if err != nil { + return err + } + + serviceReq := &service.Request{ + Commands: translateCommands(req.Commands), + PushOptions: append([]string(nil), req.PushOptions...), + 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, + }) + + result, err := svc.Execute(ctx, serviceReq) + if err != nil { + return err + } + + protoResult := translateResult(result) + + if req.Capabilities.ReportStatusV2 { + return protoSession.WriteReportStatusV2(protoResult) + } + + if req.Capabilities.ReportStatus { + return protoSession.WriteReportStatus(protoResult) + } + + return nil +} diff --git a/receivepack/translate.go b/receivepack/translate.go new file mode 100644 index 00000000..ee61b683 --- /dev/null +++ b/receivepack/translate.go @@ -0,0 +1,35 @@ +package receivepack + +import ( + protoreceive "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack" + "codeberg.org/lindenii/furgit/receivepack/internal/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 +} + +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, + }) + } + + return out +} diff --git a/receivepack/version.go b/receivepack/version.go new file mode 100644 index 00000000..42c5b38b --- /dev/null +++ b/receivepack/version.go @@ -0,0 +1,35 @@ +package receivepack + +import ( + "strings" + + common "codeberg.org/lindenii/furgit/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 +} -- cgit v1.3.1-10-gc9f91