aboutsummaryrefslogtreecommitdiff
path: root/protocol
diff options
context:
space:
mode:
Diffstat (limited to 'protocol')
-rw-r--r--protocol/v0v1/server/advertise.go55
-rw-r--r--protocol/v0v1/server/advertise_test.go101
-rw-r--r--protocol/v0v1/server/advertised_ref.go22
-rw-r--r--protocol/v0v1/server/doc.go2
-rw-r--r--protocol/v0v1/server/errors.go18
-rw-r--r--protocol/v0v1/server/frame.go20
-rw-r--r--protocol/v0v1/server/helpers.go29
-rw-r--r--protocol/v0v1/server/helpers_test.go28
-rw-r--r--protocol/v0v1/server/receivepack/capabilities.go192
-rw-r--r--protocol/v0v1/server/receivepack/doc.go2
-rw-r--r--protocol/v0v1/server/receivepack/errors.go11
-rw-r--r--protocol/v0v1/server/receivepack/helpers_test.go28
-rw-r--r--protocol/v0v1/server/receivepack/parse_test.go255
-rw-r--r--protocol/v0v1/server/receivepack/report_status_test.go189
-rw-r--r--protocol/v0v1/server/receivepack/session.go366
-rw-r--r--protocol/v0v1/server/receivepack/types.go45
-rw-r--r--protocol/v0v1/server/session.go88
-rw-r--r--protocol/v0v1/server/version.go12
18 files changed, 1463 insertions, 0 deletions
diff --git a/protocol/v0v1/server/advertise.go b/protocol/v0v1/server/advertise.go
new file mode 100644
index 00000000..dd77a12d
--- /dev/null
+++ b/protocol/v0v1/server/advertise.go
@@ -0,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()
+}
diff --git a/protocol/v0v1/server/advertise_test.go b/protocol/v0v1/server/advertise_test.go
new file mode 100644
index 00000000..91526617
--- /dev/null
+++ b/protocol/v0v1/server/advertise_test.go
@@ -0,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)
+ }
+ })
+}
diff --git a/protocol/v0v1/server/advertised_ref.go b/protocol/v0v1/server/advertised_ref.go
new file mode 100644
index 00000000..a203eb7a
--- /dev/null
+++ b/protocol/v0v1/server/advertised_ref.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/doc.go b/protocol/v0v1/server/doc.go
new file mode 100644
index 00000000..ea0b3f18
--- /dev/null
+++ b/protocol/v0v1/server/doc.go
@@ -0,0 +1,2 @@
+// Package server implements shared server-side Git protocol v0/v1 framing.
+package server
diff --git a/protocol/v0v1/server/errors.go b/protocol/v0v1/server/errors.go
new file mode 100644
index 00000000..6a456234
--- /dev/null
+++ b/protocol/v0v1/server/errors.go
@@ -0,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"}
diff --git a/protocol/v0v1/server/frame.go b/protocol/v0v1/server/frame.go
new file mode 100644
index 00000000..383284d7
--- /dev/null
+++ b/protocol/v0v1/server/frame.go
@@ -0,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
diff --git a/protocol/v0v1/server/helpers.go b/protocol/v0v1/server/helpers.go
new file mode 100644
index 00000000..9a62f714
--- /dev/null
+++ b/protocol/v0v1/server/helpers.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/helpers_test.go b/protocol/v0v1/server/helpers_test.go
new file mode 100644
index 00000000..ff2a69f3
--- /dev/null
+++ b/protocol/v0v1/server/helpers_test.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/receivepack/capabilities.go b/protocol/v0v1/server/receivepack/capabilities.go
new file mode 100644
index 00000000..68ec79ca
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/capabilities.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/receivepack/doc.go b/protocol/v0v1/server/receivepack/doc.go
new file mode 100644
index 00000000..65793831
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/doc.go
@@ -0,0 +1,2 @@
+// Package receivepack implements the receive-pack-specific server side of Git protocol v0/v1.
+package receivepack
diff --git a/protocol/v0v1/server/receivepack/errors.go b/protocol/v0v1/server/receivepack/errors.go
new file mode 100644
index 00000000..d89f8959
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/errors.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/receivepack/helpers_test.go b/protocol/v0v1/server/receivepack/helpers_test.go
new file mode 100644
index 00000000..08d281e7
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/helpers_test.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/receivepack/parse_test.go b/protocol/v0v1/server/receivepack/parse_test.go
new file mode 100644
index 00000000..f304ac44
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/parse_test.go
@@ -0,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)
+ }
+ })
+}
diff --git a/protocol/v0v1/server/receivepack/report_status_test.go b/protocol/v0v1/server/receivepack/report_status_test.go
new file mode 100644
index 00000000..fa76a85b
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/report_status_test.go
@@ -0,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)
+ }
+}
diff --git a/protocol/v0v1/server/receivepack/session.go b/protocol/v0v1/server/receivepack/session.go
new file mode 100644
index 00000000..8682c8e5
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/session.go
@@ -0,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))
+ }
+}
diff --git a/protocol/v0v1/server/receivepack/types.go b/protocol/v0v1/server/receivepack/types.go
new file mode 100644
index 00000000..521427cd
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/types.go
@@ -0,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
+}
diff --git a/protocol/v0v1/server/session.go b/protocol/v0v1/server/session.go
new file mode 100644
index 00000000..98ed199c
--- /dev/null
+++ b/protocol/v0v1/server/session.go
@@ -0,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()
+}
diff --git a/protocol/v0v1/server/version.go b/protocol/v0v1/server/version.go
new file mode 100644
index 00000000..23ae9466
--- /dev/null
+++ b/protocol/v0v1/server/version.go
@@ -0,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
+)