ref: 4b8d39764f9b54ea3090d0fea92a23025dbea30d
parent: a0d2b3a238d6e5dcdedb816cf838dd8fe003c632
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 09:24:05 EST 2026
protocol: Add v0v1 server protocol and its receivepack subprotocol
--- /dev/null
+++ b/protocol/v0v1/server/advertise.go
@@ -1,0 +1,55 @@
+package server
+
+import (
+ "fmt"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+// AdvertiseRefs writes one server ref advertisement.
+func (session *Session) AdvertiseRefs(ad Advertisement, capabilityTokens []string) error {+ if session.opts.Version == Version1 {+ err := session.enc.WriteData([]byte("version 1\n"))+ if err != nil {+ return err
+ }
+ }
+
+ capList := strings.Join(capabilityTokens, " ")
+
+ refs := sortAdvertisedRefs(ad.Refs)
+ if len(refs) == 0 {+ line := fmt.Sprintf("%s capabilities^{}\x00%s\n", objectid.Zero(session.opts.Algorithm), capList)+
+ err := session.enc.WriteData([]byte(line))
+ if err != nil {+ return err
+ }
+
+ return session.WriteFlush()
+ }
+
+ for i, entry := range refs {+ line := fmt.Sprintf("%s %s", entry.ID, entry.Name)+ if i == 0 {+ line += "\x00" + capList
+ }
+
+ err := session.enc.WriteData([]byte(line + "\n"))
+ if err != nil {+ return err
+ }
+
+ if entry.Peeled != nil {+ peeled := fmt.Sprintf("%s %s^{}\n", *entry.Peeled, entry.Name)+
+ err = session.enc.WriteData([]byte(peeled))
+ if err != nil {+ return err
+ }
+ }
+ }
+
+ return session.WriteFlush()
+}
--- /dev/null
+++ b/protocol/v0v1/server/advertise_test.go
@@ -1,0 +1,101 @@
+package server_test
+
+import (
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+ server "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+)
+
+func TestAdvertiseRefsWritesVersionOneHeadCapsAndPeeledTag(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ headID := mustHexID(t, algo, "1")
+ tagID := mustHexID(t, algo, "2")
+ peeledID := mustHexID(t, algo, "3")
+ mainID := mustHexID(t, algo, "4")
+
+ var out bufferWriteFlusher
+
+ session := server.NewSession(
+ strings.NewReader(""),+ &out,
+ server.Options{+ Version: server.Version1,
+ Algorithm: algo,
+ },
+ )
+
+ err := session.AdvertiseRefs(server.Advertisement{+ Refs: []server.AdvertisedRef{+ {Name: "refs/tags/v1", ID: tagID, Peeled: &peeledID},+ {Name: "HEAD", ID: headID},+ {Name: "refs/heads/main", ID: mainID},+ },
+ }, []string{+ "report-status",
+ "delete-refs",
+ "object-format=" + algo.String(),
+ "agent=furgit-test/1",
+ })
+ if err != nil {+ t.Fatalf("AdvertiseRefs: %v", err)+ }
+
+ got := out.String()
+ wantParts := []string{+ "000eversion 1\n",
+ headID.String() + " HEAD\x00report-status delete-refs object-format=" + algo.String() + " agent=furgit-test/1\n",
+ mainID.String() + " refs/heads/main\n",
+ tagID.String() + " refs/tags/v1\n",
+ peeledID.String() + " refs/tags/v1^{}\n",+ "0000",
+ }
+
+ for _, part := range wantParts {+ if !strings.Contains(got, part) {+ t.Fatalf("advertisement missing %q in %q", part, got)+ }
+ }
+ })
+}
+
+func TestAdvertiseRefsWritesNoRefsCapabilitiesLine(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ var out bufferWriteFlusher
+
+ session := server.NewSession(
+ strings.NewReader(""),+ &out,
+ server.Options{+ Algorithm: algo,
+ },
+ )
+
+ err := session.AdvertiseRefs(server.Advertisement{}, []string{+ "report-status",
+ "object-format=" + algo.String(),
+ })
+ if err != nil {+ t.Fatalf("AdvertiseRefs: %v", err)+ }
+
+ got := out.String()
+
+ want := objectid.Zero(algo).String() + " capabilities^{}\x00report-status object-format=" + algo.String() + "\n"+ if !strings.Contains(got, want) {+ t.Fatalf("unexpected no-refs advertisement %q", got)+ }
+ })
+}
--- /dev/null
+++ b/protocol/v0v1/server/advertised_ref.go
@@ -1,0 +1,22 @@
+package server
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// AdvertisedRef is one ref entry in one v0/v1 server advertisement.
+type AdvertisedRef struct {+ // Name is the advertised reference name. It may be HEAD or one full
+ // reference name.
+ Name string
+ // ID is the object ID currently advertised for Name.
+ ID objectid.ObjectID
+ // Peeled is the peeled annotated-tag target when available.
+ //
+ // If set, advertisement writes one immediate "<name>^{}" line after the+ // main entry, matching Git's advertisement rules.
+ Peeled *objectid.ObjectID
+}
+
+// Advertisement is one server-side ref advertisement.
+type Advertisement struct {+ Refs []AdvertisedRef
+}
--- /dev/null
+++ b/protocol/v0v1/server/doc.go
@@ -1,0 +1,2 @@
+// Package server implements shared server-side Git protocol v0/v1 framing.
+package server
--- /dev/null
+++ b/protocol/v0v1/server/errors.go
@@ -1,0 +1,18 @@
+package server
+
+// ProtocolError reports one malformed or unsupported protocol input.
+type ProtocolError struct {+ Reason string
+}
+
+// Error returns the formatted error string.
+func (err *ProtocolError) Error() string {+ return "protocol/v0v1/server: protocol error: " + err.Reason
+}
+
+// ErrUnexpectedPacket reports one unexpected pkt-line control packet.
+var ErrUnexpectedPacket = &ProtocolError{Reason: "unexpected control packet"}+
+// ErrSideBandNotEnabled reports one attempt to write sideband frames without a
+// negotiated side-band-64k session.
+var ErrSideBandNotEnabled = &ProtocolError{Reason: "side-band-64k not enabled"}--- /dev/null
+++ b/protocol/v0v1/server/frame.go
@@ -1,0 +1,20 @@
+package server
+
+import "codeberg.org/lindenii/furgit/format/pktline"
+
+// FrameType identifies one low-level v0/v1 server pkt-line frame type.
+type FrameType = pktline.PacketType
+
+const (
+ // FrameData is one data pkt-line.
+ FrameData = pktline.PacketData
+ // FrameFlush is one flush-pkt.
+ FrameFlush = pktline.PacketFlush
+ // FrameDelim is one delim-pkt.
+ FrameDelim = pktline.PacketDelim
+ // FrameResponseEnd is one response-end-pkt.
+ FrameResponseEnd = pktline.PacketResponseEnd
+)
+
+// Frame is one decoded low-level pkt-line frame.
+type Frame = pktline.Frame
--- /dev/null
+++ b/protocol/v0v1/server/helpers.go
@@ -1,0 +1,29 @@
+package server
+
+import (
+ "slices"
+)
+
+func sortAdvertisedRefs(refs []AdvertisedRef) []AdvertisedRef {+ out := append([]AdvertisedRef(nil), refs...)
+ slices.SortFunc(out, func(left, right AdvertisedRef) int {+ if left.Name == "HEAD" && right.Name != "HEAD" {+ return -1
+ }
+
+ if left.Name != "HEAD" && right.Name == "HEAD" {+ return 1
+ }
+
+ switch {+ case left.Name < right.Name:
+ return -1
+ case left.Name > right.Name:
+ return 1
+ default:
+ return 0
+ }
+ })
+
+ return out
+}
--- /dev/null
+++ b/protocol/v0v1/server/helpers_test.go
@@ -1,0 +1,28 @@
+package server_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+type bufferWriteFlusher struct {+ bytes.Buffer
+}
+
+func (bufferWriteFlusher) Flush() error {+ return nil
+}
+
+func mustHexID(tb testing.TB, algo objectid.Algorithm, digit string) objectid.ObjectID {+ tb.Helper()
+
+ id, err := objectid.ParseHex(algo, strings.Repeat(digit, algo.HexLen()))
+ if err != nil {+ tb.Fatalf("objectid.ParseHex(%q): %v", strings.Repeat(digit, algo.HexLen()), err)+ }
+
+ return id
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/capabilities.go
@@ -1,0 +1,192 @@
+package receivepack
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+// Capabilities describes one receive-pack capability set.
+type Capabilities struct {+ ReportStatus bool
+ ReportStatusV2 bool
+ DeleteRefs bool
+ SideBand64K bool
+ Quiet bool
+ Atomic bool
+ OfsDelta bool
+ PushOptions bool
+ PushCertNonce string
+ ObjectFormat objectid.Algorithm
+ SessionID string
+ Agent string
+}
+
+// Normalize returns one normalized copy of caps.
+func (caps Capabilities) Normalize(defaultAlgorithm objectid.Algorithm) Capabilities {+ if caps.ObjectFormat == objectid.AlgorithmUnknown {+ caps.ObjectFormat = defaultAlgorithm
+ }
+
+ return caps
+}
+
+// Tokens returns capabilities in Git advertisement order.
+func (caps Capabilities) Tokens(defaultAlgorithm objectid.Algorithm) []string {+ caps = caps.Normalize(defaultAlgorithm)
+
+ tokens := make([]string, 0, 11)
+ if caps.ReportStatus {+ tokens = append(tokens, "report-status")
+ }
+
+ if caps.ReportStatusV2 {+ tokens = append(tokens, "report-status-v2")
+ }
+
+ if caps.DeleteRefs {+ tokens = append(tokens, "delete-refs")
+ }
+
+ if caps.SideBand64K {+ tokens = append(tokens, "side-band-64k")
+ }
+
+ if caps.Quiet {+ tokens = append(tokens, "quiet")
+ }
+
+ if caps.Atomic {+ tokens = append(tokens, "atomic")
+ }
+
+ if caps.OfsDelta {+ tokens = append(tokens, "ofs-delta")
+ }
+
+ if caps.PushCertNonce != "" {+ tokens = append(tokens, "push-cert="+caps.PushCertNonce)
+ }
+
+ if caps.PushOptions {+ tokens = append(tokens, "push-options")
+ }
+
+ if caps.SessionID != "" {+ tokens = append(tokens, "session-id="+caps.SessionID)
+ }
+
+ if caps.ObjectFormat != objectid.AlgorithmUnknown {+ tokens = append(tokens, "object-format="+caps.ObjectFormat.String())
+ }
+
+ if caps.Agent != "" {+ tokens = append(tokens, "agent="+caps.Agent)
+ }
+
+ return tokens
+}
+
+func (caps Capabilities) supportsToken(token string, defaultAlgorithm objectid.Algorithm) bool {+ name, value, _ := strings.Cut(token, "=")
+
+ switch name {+ case "report-status":
+ return caps.ReportStatus && value == ""
+ case "report-status-v2":
+ return caps.ReportStatusV2 && value == ""
+ case "delete-refs":
+ return caps.DeleteRefs && value == ""
+ case "side-band-64k":
+ return caps.SideBand64K && value == ""
+ case "quiet":
+ return caps.Quiet && value == ""
+ case "atomic":
+ return caps.Atomic && value == ""
+ case "ofs-delta":
+ return caps.OfsDelta && value == ""
+ case "push-options":
+ return caps.PushOptions && value == ""
+ case "push-cert":
+ return caps.PushCertNonce != "" && value != ""
+ case "object-format":
+ if value == "" {+ return false
+ }
+
+ algo, ok := objectid.ParseAlgorithm(value)
+
+ return ok && algo == caps.Normalize(defaultAlgorithm).ObjectFormat
+ case "session-id":
+ return caps.SessionID != "" && value != ""
+ case "agent":
+ return caps.Agent != "" && value != ""
+ default:
+ return false
+ }
+}
+
+func parseCapabilityList(s string) ([]string, error) {+ s = strings.TrimSuffix(s, "\n")
+ if s == "" {+ return nil, nil
+ }
+
+ tokens := strings.Fields(s)
+ if slices.Contains(tokens, "") {+ return nil, &ProtocolError{Reason: "empty capability token"}+ }
+
+ return tokens, nil
+}
+
+func parseRequestedCapabilities(
+ tokens []string,
+ supported Capabilities,
+ defaultAlgorithm objectid.Algorithm,
+) (Capabilities, error) {+ var requested Capabilities
+
+ requested.ObjectFormat = defaultAlgorithm
+
+ for _, token := range tokens {+ if !supported.supportsToken(token, defaultAlgorithm) {+ return Capabilities{}, &ProtocolError{+ Reason: fmt.Sprintf("unsupported capability %q", token),+ }
+ }
+
+ name, value, _ := strings.Cut(token, "=")
+ switch name {+ case "report-status":
+ requested.ReportStatus = true
+ case "report-status-v2":
+ requested.ReportStatusV2 = true
+ case "delete-refs":
+ requested.DeleteRefs = true
+ case "side-band-64k":
+ requested.SideBand64K = true
+ case "quiet":
+ requested.Quiet = true
+ case "atomic":
+ requested.Atomic = true
+ case "ofs-delta":
+ requested.OfsDelta = true
+ case "push-options":
+ requested.PushOptions = true
+ case "push-cert":
+ requested.PushCertNonce = value
+ case "object-format":
+ algo, _ := objectid.ParseAlgorithm(value)
+ requested.ObjectFormat = algo
+ case "session-id":
+ requested.SessionID = value
+ case "agent":
+ requested.Agent = value
+ }
+ }
+
+ return requested, nil
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/doc.go
@@ -1,0 +1,2 @@
+// Package receivepack implements the receive-pack-specific server side of Git protocol v0/v1.
+package receivepack
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/errors.go
@@ -1,0 +1,11 @@
+package receivepack
+
+// ProtocolError reports one malformed or unsupported receive-pack protocol input.
+type ProtocolError struct {+ Reason string
+}
+
+// Error returns the formatted error string.
+func (err *ProtocolError) Error() string {+ return "protocol/v0v1/server/receivepack: protocol error: " + err.Reason
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/helpers_test.go
@@ -1,0 +1,28 @@
+package receivepack_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+type bufferWriteFlusher struct {+ bytes.Buffer
+}
+
+func (bufferWriteFlusher) Flush() error {+ return nil
+}
+
+func mustHexID(tb testing.TB, algo objectid.Algorithm, digit string) objectid.ObjectID {+ tb.Helper()
+
+ id, err := objectid.ParseHex(algo, strings.Repeat(digit, algo.HexLen()))
+ if err != nil {+ tb.Fatalf("objectid.ParseHex(%q): %v", strings.Repeat(digit, algo.HexLen()), err)+ }
+
+ return id
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/parse_test.go
@@ -1,0 +1,255 @@
+package receivepack_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+ common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+ receivepack "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack"
+)
+
+func TestReadRequestParsesCommandsAndPushOptions(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ oldZero := objectid.Zero(algo).String()
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte(
+ oldZero + " " + oneID.String() + " refs/heads/main\x00report-status push-options object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {+ t.Fatalf("WriteData(first): %v", err)+ }
+
+ err = enc.WriteData([]byte(
+ oneID.String() + " " + oldZero + " refs/heads/old\n",
+ ))
+ if err != nil {+ t.Fatalf("WriteData(second): %v", err)+ }
+
+ err = enc.WriteFlush()
+ if err != nil {+ t.Fatalf("WriteFlush(commands): %v", err)+ }
+
+ err = enc.WriteData([]byte("ci.skip\n"))+ if err != nil {+ t.Fatalf("WriteData(push-option): %v", err)+ }
+
+ err = enc.WriteFlush()
+ if err != nil {+ t.Fatalf("WriteFlush(push-options): %v", err)+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{+ ReportStatus: true,
+ PushOptions: true,
+ ObjectFormat: algo,
+ })
+
+ req, err := session.ReadRequest()
+ if err != nil {+ t.Fatalf("ReadRequest: %v", err)+ }
+
+ if len(req.Commands) != 2 {+ t.Fatalf("len(req.Commands) = %d, want 2", len(req.Commands))+ }
+
+ if !req.Capabilities.ReportStatus || !req.Capabilities.PushOptions {+ t.Fatalf("capabilities = %#v", req.Capabilities)+ }
+
+ if len(req.PushOptions) != 1 || req.PushOptions[0] != "ci.skip" {+ t.Fatalf("push options = %#v", req.PushOptions)+ }
+
+ if !req.PackExpected {+ t.Fatalf("PackExpected = false, want true")+ }
+
+ if req.DeleteOnly {+ t.Fatalf("DeleteOnly = true, want false")+ }
+ })
+}
+
+func TestReadRequestDeleteOnlyDoesNotExpectPack(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte(
+ oneID.String() + " " + objectid.Zero(algo).String() + " refs/heads/old\x00delete-refs object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {+ t.Fatalf("WriteData: %v", err)+ }
+
+ err = enc.WriteFlush()
+ if err != nil {+ t.Fatalf("WriteFlush: %v", err)+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{+ DeleteRefs: true,
+ ObjectFormat: algo,
+ })
+
+ req, err := session.ReadRequest()
+ if err != nil {+ t.Fatalf("ReadRequest: %v", err)+ }
+
+ if req.PackExpected {+ t.Fatalf("PackExpected = true, want false")+ }
+
+ if !req.DeleteOnly {+ t.Fatalf("DeleteOnly = false, want true")+ }
+ })
+}
+
+func TestReadRequestRejectsUnsupportedCapability(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte(
+ objectid.Zero(algo).String() + " " + oneID.String() + " refs/heads/main\x00atomic object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {+ t.Fatalf("WriteData: %v", err)+ }
+
+ err = enc.WriteFlush()
+ if err != nil {+ t.Fatalf("WriteFlush: %v", err)+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{ObjectFormat: algo})+
+ _, err = session.ReadRequest()
+ if err == nil {+ t.Fatalf("ReadRequest error = nil, want error")+ }
+
+ protocolErr, ok := errors.AsType[*receivepack.ProtocolError](err)
+ if !ok {+ t.Fatalf("errors.AsType[*receivepack.ProtocolError](%T) = false", err)+ }
+
+ if !strings.Contains(protocolErr.Reason, "unsupported capability") {+ t.Fatalf("ProtocolError.Reason = %q", protocolErr.Reason)+ }
+ })
+}
+
+func TestReadRequestParsesPushCertificate(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ oneID := mustHexID(t, algo, "1")
+
+ var wire bufferWriteFlusher
+
+ enc := pktline.NewEncoder(&wire)
+
+ err := enc.WriteData([]byte("push-cert\x00push-cert=nonce object-format=" + algo.String() + "\n"))+ if err != nil {+ t.Fatalf("WriteData(push-cert): %v", err)+ }
+
+ lines := []string{+ "certificate version 0.1\n",
+ "pusher Example <example@example.com>\n",
+ "nonce nonce\n",
+ "push-option ci.skip\n",
+ "\n",
+ objectid.Zero(algo).String() + " " + oneID.String() + " refs/heads/main\n",
+ "-----BEGIN PGP SIGNATURE-----\n",
+ "abcdef\n",
+ "push-cert-end\n",
+ }
+
+ for _, line := range lines {+ err = enc.WriteData([]byte(line))
+ if err != nil {+ t.Fatalf("WriteData(%q): %v", line, err)+ }
+ }
+
+ err = enc.WriteFlush()
+ if err != nil {+ t.Fatalf("WriteFlush: %v", err)+ }
+
+ base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{+ PushCertNonce: "server-nonce",
+ ObjectFormat: algo,
+ })
+
+ req, err := session.ReadRequest()
+ if err != nil {+ t.Fatalf("ReadRequest: %v", err)+ }
+
+ if req.PushCert == nil {+ t.Fatalf("PushCert = nil, want parsed certificate")+ }
+
+ if len(req.Commands) != 1 {+ t.Fatalf("len(req.Commands) = %d, want 1", len(req.Commands))+ }
+
+ if len(req.PushCert.EmbeddedOption) != 1 || req.PushCert.EmbeddedOption[0] != "ci.skip" {+ t.Fatalf("embedded options = %#v", req.PushCert.EmbeddedOption)+ }
+ })
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/report_status_test.go
@@ -1,0 +1,189 @@
+package receivepack_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+ common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+ receivepack "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack"
+)
+
+func TestWriteReportStatusWritesClassicStatus(t *testing.T) {+ t.Parallel()
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(""), &out, common.Options{})+ session := receivepack.NewSession(base, receivepack.Capabilities{})+
+ err := session.WriteReportStatus(receivepack.ReportStatusResult{+ Commands: []receivepack.CommandResult{+ {Name: "refs/heads/main"},+ {Name: "refs/heads/dev", Error: "non-fast-forward"},+ },
+ })
+ if err != nil {+ t.Fatalf("WriteReportStatus: %v", err)+ }
+
+ got := out.String()
+ wantParts := []string{+ "unpack ok\n",
+ "ok refs/heads/main\n",
+ "ng refs/heads/dev non-fast-forward\n",
+ "0000",
+ }
+
+ for _, part := range wantParts {+ if !strings.Contains(got, part) {+ t.Fatalf("report-status missing %q in %q", part, got)+ }
+ }
+}
+
+func TestWriteReportStatusUsesSideBand64KWhenNegotiated(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ var requestWire bufferWriteFlusher
+
+ requestEnc := pktline.NewEncoder(&requestWire)
+
+ err := requestEnc.WriteData([]byte(
+ objectid.Zero(algo).String() + " " + mustHexID(t, algo, "1").String() + " refs/heads/main\x00report-status side-band-64k object-format=" + algo.String() + "\n",
+ ))
+ if err != nil {+ t.Fatalf("WriteData(request): %v", err)+ }
+
+ err = requestEnc.WriteFlush()
+ if err != nil {+ t.Fatalf("WriteFlush(request): %v", err)+ }
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(requestWire.String()), &out, common.Options{+ Algorithm: algo,
+ })
+ session := receivepack.NewSession(base, receivepack.Capabilities{+ ReportStatus: true,
+ SideBand64K: true,
+ ObjectFormat: algo,
+ })
+
+ _, err = session.ReadRequest()
+ if err != nil {+ t.Fatalf("ReadRequest: %v", err)+ }
+
+ err = session.WriteReportStatus(receivepack.ReportStatusResult{+ Commands: []receivepack.CommandResult{+ {Name: "refs/heads/main"},+ },
+ })
+ if err != nil {+ t.Fatalf("WriteReportStatus: %v", err)+ }
+
+ dec := sideband64k.NewDecoder(strings.NewReader(out.String()), sideband64k.ReadOptions{})+
+ frame, err := dec.ReadFrame()
+ if err != nil {+ t.Fatalf("ReadFrame(unpack): %v", err)+ }
+
+ if frame.Type != sideband64k.FrameData || string(frame.Payload) != "unpack ok\n" {+ t.Fatalf("first frame = %#v", frame)+ }
+
+ frame, err = dec.ReadFrame()
+ if err != nil {+ t.Fatalf("ReadFrame(ok): %v", err)+ }
+
+ if frame.Type != sideband64k.FrameData || string(frame.Payload) != "ok refs/heads/main\n" {+ t.Fatalf("second frame = %#v", frame)+ }
+
+ frame, err = dec.ReadFrame()
+ if err != nil {+ t.Fatalf("ReadFrame(flush): %v", err)+ }
+
+ if frame.Type != sideband64k.FrameFlush {+ t.Fatalf("flush frame.Type = %v, want FrameFlush", frame.Type)+ }
+ })
+}
+
+func TestWriteReportStatusV2WritesOptionLines(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ oldID := mustHexID(t, algo, "1")
+ newID := mustHexID(t, algo, "2")
+
+ var out bufferWriteFlusher
+
+ base := common.NewSession(strings.NewReader(""), &out, common.Options{})+ session := receivepack.NewSession(base, receivepack.Capabilities{})+
+ err := session.WriteReportStatusV2(receivepack.ReportStatusResult{+ Commands: []receivepack.CommandResult{+ {+ Name: "refs/pseudo/proc",
+ RefName: "refs/heads/main",
+ OldID: &oldID,
+ NewID: &newID,
+ ForcedUpdate: true,
+ },
+ {Name: "refs/heads/dev", Error: "rejected"},+ },
+ })
+ if err != nil {+ t.Fatalf("WriteReportStatusV2: %v", err)+ }
+
+ got := out.String()
+ wantParts := []string{+ "unpack ok\n",
+ "ok refs/pseudo/proc\n",
+ "option refname refs/heads/main\n",
+ "option old-oid " + oldID.String() + "\n",
+ "option new-oid " + newID.String() + "\n",
+ "option forced-update\n",
+ "ng refs/heads/dev rejected\n",
+ "0000",
+ }
+
+ for _, part := range wantParts {+ if !strings.Contains(got, part) {+ t.Fatalf("report-status-v2 missing %q in %q", part, got)+ }
+ }
+ })
+}
+
+func TestWriteProgressRequiresSideBand64K(t *testing.T) {+ t.Parallel()
+
+ base := common.NewSession(strings.NewReader(""), &bufferWriteFlusher{}, common.Options{})+ session := receivepack.NewSession(base, receivepack.Capabilities{})+
+ err := session.WriteProgress([]byte("progress\n"))+ if !errors.Is(err, common.ErrSideBandNotEnabled) {+ t.Fatalf("WriteProgress error = %v, want %v", err, common.ErrSideBandNotEnabled)+ }
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/session.go
@@ -1,0 +1,366 @@
+package receivepack
+
+import (
+ "fmt"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+)
+
+// Session is one stateful server-side receive-pack protocol session.
+type Session struct {+ base *common.Session
+ supported Capabilities
+ negotiated Capabilities
+}
+
+// NewSession creates one receive-pack session over one common server session.
+func NewSession(base *common.Session, supported Capabilities) *Session {+ return &Session{+ base: base,
+ supported: supported,
+ }
+}
+
+// AdvertiseRefs writes one receive-pack ref advertisement.
+func (session *Session) AdvertiseRefs(ad common.Advertisement) error {+ return session.base.AdvertiseRefs(ad, session.supported.Tokens(session.base.Algorithm()))
+}
+
+// ReadRequest reads one receive-pack request through optional push-options.
+func (session *Session) ReadRequest() (*Request, error) {+ req := &Request{}+
+ var sawCommands bool
+
+ for {+ frame, err := session.base.ReadFrame()
+ if err != nil {+ return nil, err
+ }
+
+ switch frame.Type {+ case common.FrameFlush:
+ goto afterCommands
+ case common.FrameData:
+ case common.FrameDelim, common.FrameResponseEnd:
+ return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}+ }
+
+ payload := string(frame.Payload)
+ if strings.HasPrefix(payload, "shallow ") {+ line := trimOneLF(payload)
+
+ shallowID, err := parseObjectID(session.base.Algorithm(), line[len("shallow "):])+ if err != nil {+ return nil, err
+ }
+
+ req.Shallow = append(req.Shallow, shallowID)
+
+ continue
+ }
+
+ if strings.HasPrefix(payload, "push-cert\x00") {+ if sawCommands {+ return nil, &ProtocolError{Reason: "got both push certificate and unsigned commands"}+ }
+
+ capabilityTokens, err := parseCapabilityList(payload[len("push-cert\x00"):])+ if err != nil {+ return nil, err
+ }
+
+ requested, err := parseRequestedCapabilities(
+ capabilityTokens,
+ session.supported,
+ session.base.Algorithm(),
+ )
+ if err != nil {+ return nil, err
+ }
+
+ req.Capabilities = requested
+
+ cert, err := session.readPushCertificate()
+ if err != nil {+ return nil, err
+ }
+
+ req.PushCert = cert
+ req.Commands = append(req.Commands, cert.Commands...)
+ sawCommands = true
+
+ continue
+ }
+
+ line := trimOneLF(payload)
+ if !sawCommands && strings.Contains(line, "\x00") {+ commandPart, capPart, _ := strings.Cut(line, "\x00")
+
+ capabilityTokens, err := parseCapabilityList(capPart)
+ if err != nil {+ return nil, err
+ }
+
+ requested, err := parseRequestedCapabilities(
+ capabilityTokens,
+ session.supported,
+ session.base.Algorithm(),
+ )
+ if err != nil {+ return nil, err
+ }
+
+ req.Capabilities = requested
+ line = commandPart
+ }
+
+ cmd, err := parseCommand(session.base.Algorithm(), line)
+ if err != nil {+ return nil, err
+ }
+
+ req.Commands = append(req.Commands, cmd)
+ sawCommands = true
+ }
+
+afterCommands:
+ if req.Capabilities.PushOptions {+ for {+ frame, err := session.base.ReadFrame()
+ if err != nil {+ return nil, err
+ }
+
+ switch frame.Type {+ case common.FrameFlush:
+ goto afterPushOptions
+ case common.FrameData:
+ req.PushOptions = append(req.PushOptions, trimOneLF(string(frame.Payload)))
+ case common.FrameDelim, common.FrameResponseEnd:
+ return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}+ }
+ }
+ }
+
+afterPushOptions:
+ req.DeleteOnly = deleteOnly(req.Commands)
+
+ req.PackExpected = len(req.Commands) > 0 && !req.DeleteOnly
+
+ session.negotiated = req.Capabilities
+
+ if req.Capabilities.SideBand64K {+ session.base.EnableSideBand64K()
+ }
+
+ return req, nil
+}
+
+// WriteReportStatus writes one classic report-status response.
+func (session *Session) WriteReportStatus(result ReportStatusResult) error {+ unpackResult := "ok"
+ if result.UnpackError != "" {+ unpackResult = result.UnpackError
+ }
+
+ err := session.base.WriteData(fmt.Appendf(nil, "unpack %s\n", unpackResult))
+ if err != nil {+ return err
+ }
+
+ for _, command := range result.Commands {+ line := fmt.Sprintf("ok %s\n", command.Name)+ if command.Error != "" {+ line = fmt.Sprintf("ng %s %s\n", command.Name, command.Error)+ }
+
+ err = session.base.WriteData([]byte(line))
+ if err != nil {+ return err
+ }
+ }
+
+ return session.base.WriteFlush()
+}
+
+// WriteReportStatusV2 writes one report-status-v2 response.
+func (session *Session) WriteReportStatusV2(result ReportStatusResult) error {+ unpackResult := "ok"
+ if result.UnpackError != "" {+ unpackResult = result.UnpackError
+ }
+
+ err := session.base.WriteData(fmt.Appendf(nil, "unpack %s\n", unpackResult))
+ if err != nil {+ return err
+ }
+
+ for _, command := range result.Commands {+ if command.Error != "" {+ err = session.base.WriteData(fmt.Appendf(nil, "ng %s %s\n", command.Name, command.Error))
+ if err != nil {+ return err
+ }
+
+ continue
+ }
+
+ err = session.base.WriteData(fmt.Appendf(nil, "ok %s\n", command.Name))
+ if err != nil {+ return err
+ }
+
+ if command.RefName != "" {+ err = session.base.WriteData(fmt.Appendf(nil, "option refname %s\n", command.RefName))
+ if err != nil {+ return err
+ }
+ }
+
+ if command.OldID != nil {+ err = session.base.WriteData(fmt.Appendf(nil, "option old-oid %s\n", *command.OldID))
+ if err != nil {+ return err
+ }
+ }
+
+ if command.NewID != nil {+ err = session.base.WriteData(fmt.Appendf(nil, "option new-oid %s\n", *command.NewID))
+ if err != nil {+ return err
+ }
+ }
+
+ if command.ForcedUpdate {+ err = session.base.WriteData([]byte("option forced-update\n"))+ if err != nil {+ return err
+ }
+ }
+ }
+
+ return session.base.WriteFlush()
+}
+
+// WriteProgress writes one progress packet.
+func (session *Session) WriteProgress(p []byte) error {+ return session.base.WriteProgress(p)
+}
+
+// WriteError writes one fatal error packet.
+func (session *Session) WriteError(p []byte) error {+ return session.base.WriteError(p)
+}
+
+func trimOneLF(s string) string {+ return strings.TrimSuffix(s, "\n")
+}
+
+func parseObjectID(algo objectid.Algorithm, s string) (objectid.ObjectID, error) {+ id, err := objectid.ParseHex(algo, s)
+ if err != nil {+ return objectid.ObjectID{}, &ProtocolError{+ Reason: fmt.Sprintf("invalid object id %q", s),+ }
+ }
+
+ return id, nil
+}
+
+func commandIsDelete(cmd Command) bool {+ return cmd.NewID == objectid.Zero(cmd.NewID.Algorithm())
+}
+
+func deleteOnly(commands []Command) bool {+ if len(commands) == 0 {+ return false
+ }
+
+ for _, cmd := range commands {+ if !commandIsDelete(cmd) {+ return false
+ }
+ }
+
+ return true
+}
+
+func parseCommand(algo objectid.Algorithm, line string) (Command, error) {+ fields := strings.Fields(line)
+ if len(fields) != 3 {+ return Command{}, &ProtocolError{Reason: fmt.Sprintf("malformed command %q", line)}+ }
+
+ oldID, err := parseObjectID(algo, fields[0])
+ if err != nil {+ return Command{}, err+ }
+
+ newID, err := parseObjectID(algo, fields[1])
+ if err != nil {+ return Command{}, err+ }
+
+ return Command{OldID: oldID, NewID: newID, Name: fields[2]}, nil+}
+
+func (session *Session) readPushCertificate() (*PushCertificate, error) {+ cert := &PushCertificate{}+ inCommands := false
+ inSignature := false
+
+ for {+ frame, err := session.base.ReadFrame()
+ if err != nil {+ return nil, err
+ }
+
+ switch frame.Type {+ case common.FrameFlush:
+ return nil, &ProtocolError{Reason: "unexpected flush inside push certificate"}+ case common.FrameData:
+ case common.FrameDelim, common.FrameResponseEnd:
+ return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}+ }
+
+ line := string(frame.Payload)
+ if line == "push-cert-end\n" {+ return cert, nil
+ }
+
+ if !inCommands {+ if line == "\n" {+ inCommands = true
+
+ continue
+ }
+
+ trimmed := trimOneLF(line)
+ cert.HeaderLines = append(cert.HeaderLines, trimmed)
+
+ if strings.HasPrefix(trimmed, "push-option ") {+ cert.EmbeddedOption = append(cert.EmbeddedOption, trimmed[len("push-option "):])+ }
+
+ continue
+ }
+
+ if !inSignature {+ trimmed := trimOneLF(line)
+
+ cmd, err := parseCommand(session.base.Algorithm(), trimmed)
+ if err == nil {+ cert.Commands = append(cert.Commands, cmd)
+
+ continue
+ }
+
+ inSignature = true
+ }
+
+ cert.SignatureLines = append(cert.SignatureLines, trimOneLF(line))
+ }
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/types.go
@@ -1,0 +1,45 @@
+package receivepack
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// Command is one requested reference update.
+type Command struct {+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+ Name string
+}
+
+// PushCertificate is one parsed push certificate block.
+type PushCertificate struct {+ HeaderLines []string
+ EmbeddedOption []string
+ Commands []Command
+ SignatureLines []string
+}
+
+// Request is one parsed receive-pack request.
+type Request struct {+ Capabilities Capabilities
+ Shallow []objectid.ObjectID
+ Commands []Command
+ PushCert *PushCertificate
+ PushOptions []string
+ PackExpected bool
+ DeleteOnly bool
+}
+
+// CommandResult is one per-command report-status result.
+type CommandResult struct {+ Name string
+ Error string
+ RefName string
+ OldID *objectid.ObjectID
+ NewID *objectid.ObjectID
+ ForcedUpdate bool
+}
+
+// ReportStatusResult is one report-status payload.
+type ReportStatusResult struct {+ UnpackError string
+ Commands []CommandResult
+}
--- /dev/null
+++ b/protocol/v0v1/server/session.go
@@ -1,0 +1,88 @@
+package server
+
+import (
+ "io"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+ "codeberg.org/lindenii/furgit/format/sideband64k"
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+// Options configures one server-side v0/v1 session.
+type Options struct {+ // Version selects protocol v0 or v1 framing.
+ Version Version
+ // Algorithm is the repository object ID algorithm for this session.
+ Algorithm objectid.Algorithm
+}
+
+// Session is one stateful server-side v0/v1 server protocol session.
+type Session struct {+ dec *pktline.Decoder
+ enc *pktline.Encoder
+ sideband *sideband64k.Encoder
+ opts Options
+ useSideBand bool
+}
+
+// NewSession creates one v0/v1 server session over r and w.
+func NewSession(r io.Reader, w pktline.WriteFlusher, opts Options) *Session {+ return &Session{+ dec: pktline.NewDecoder(r, pktline.ReadOptions{}),+ enc: pktline.NewEncoder(w),
+ sideband: sideband64k.NewEncoder(w),
+ opts: opts,
+ }
+}
+
+// Algorithm returns the session object ID algorithm.
+func (session *Session) Algorithm() objectid.Algorithm {+ return session.opts.Algorithm
+}
+
+// ReadFrame reads one low-level pkt-line frame from the session input.
+func (session *Session) ReadFrame() (Frame, error) {+ return session.dec.ReadFrame()
+}
+
+// EnableSideBand64K enables side-band-64k output framing for subsequent data,
+// progress, error, and flush writes.
+func (session *Session) EnableSideBand64K() {+ session.useSideBand = true
+}
+
+// WriteData writes one primary output packet.
+func (session *Session) WriteData(p []byte) error {+ if session.useSideBand {+ return session.sideband.WriteData(p)
+ }
+
+ return session.enc.WriteData(p)
+}
+
+// WriteProgress writes one progress packet.
+func (session *Session) WriteProgress(p []byte) error {+ if !session.useSideBand {+ return ErrSideBandNotEnabled
+ }
+
+ return session.sideband.WriteProgress(p)
+}
+
+// WriteError writes one fatal error packet.
+func (session *Session) WriteError(p []byte) error {+ if !session.useSideBand {+ return ErrSideBandNotEnabled
+ }
+
+ return session.sideband.WriteError(p)
+}
+
+// WriteFlush writes one trailing flush packet.
+func (session *Session) WriteFlush() error {+ if session.useSideBand {+ return session.sideband.WriteFlush()
+ }
+
+ return session.enc.WriteFlush()
+}
--- /dev/null
+++ b/protocol/v0v1/server/version.go
@@ -1,0 +1,12 @@
+package server
+
+// Version identifies the protocol version used on one v0/v1 server session.
+type Version uint8
+
+const (
+ // Version0 is the original protocol framing with no leading version line.
+ Version0 Version = iota
+ // Version1 is protocol v1, which is v0 plus one leading "version 1\n"
+ // pkt-line before ref advertisement.
+ Version1
+)
--
⑨