diff --git a/knotfeed/frame.go b/knotfeed/frame.go index 86643be32..2102e2130 100644 --- a/knotfeed/frame.go +++ b/knotfeed/frame.go @@ -8,11 +8,14 @@ import ( "io" "log/slog" "strings" + "unicode" comatproto "github.com/bluesky-social/indigo/api/atproto" atprotorepo "github.com/bluesky-social/indigo/atproto/repo" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/samber/lo" cbg "github.com/whyrusleeping/cbor-gen" + "tangled.org/core/tapc" ) const ( @@ -26,6 +29,7 @@ const ( type Message struct { Type string + Feed Feed Commit *Commit InfoName string Error string @@ -33,18 +37,60 @@ type Message struct { } type RecordOp struct { - Action string - Collection string - Rkey string + Action tapc.RecordAction + Collection syntax.NSID + Rkey syntax.RecordKey Bytes []byte + Prior PriorSha } func (op RecordOp) Deleted() bool { - return op.Action == "delete" + return op.Action == tapc.RecordDeleteAction } +const ( + sha1HexLen = 40 + sha256HexLen = 64 +) + +type ObjectID struct { + hex string +} + +func ParseObjectID(reported string) (ObjectID, bool) { + sized := len(reported) == sha1HexLen || len(reported) == sha256HexLen + if !sized || strings.ContainsFunc(reported, notHexDigit) { + return ObjectID{}, false + } + if strings.Trim(reported, "0") == "" { + return ObjectID{}, true + } + return ObjectID{hex: reported}, true +} + +func (o ObjectID) String() string { return o.hex } + +func (o ObjectID) IsZero() bool { return o.hex == "" } + +func notHexDigit(r rune) bool { return !strings.ContainsRune(hexDigits, unicode.ToLower(r)) } + +type PriorSha struct { + sha ObjectID + known bool +} + +func ParsePriorSha(reported string) PriorSha { + sha, ok := ParseObjectID(reported) + if !ok { + return PriorSha{} + } + return PriorSha{sha: sha, known: true} +} + +func (p PriorSha) Sha() (ObjectID, bool) { return p.sha, p.known } + type Commit struct { - Repo string + Repo syntax.DID Seq int64 Rev string Records []RecordOp @@ -178,8 +224,12 @@ func decodeTyped(msgType string, r io.Reader, log *slog.Logger) (Message, error) } func resolveCommit(evt *comatproto.SyncSubscribeRepos_Commit, log *slog.Logger) (*Commit, error) { + repoDid, err := syntax.ParseDID(evt.Repo) + if err != nil { + return nil, fmt.Errorf("commit frame names repo %q, which isn't a did: %w", evt.Repo, err) + } commit := &Commit{ - Repo: evt.Repo, + Repo: repoDid, Seq: evt.Seq, Rev: evt.Rev, } @@ -189,38 +239,58 @@ func resolveCommit(evt *comatproto.SyncSubscribeRepos_Commit, log *slog.Logger) ctx := context.Background() _, repo, err := atprotorepo.LoadRepoFromCAR(ctx, bytes.NewReader(evt.Blocks)) if err != nil { - log.Error("commit frame blocks didn't decode as a car, dropping the commit", "repo", evt.Repo, "seq", evt.Seq, "err", err) + log.Error("commit frame blocks didn't decode as a car, dropping the commit", "repo", repoDid, "seq", evt.Seq, "err", err) return commit, nil } - records := make([]RecordOp, 0, len(evt.Ops)) - for _, op := range evt.Ops { + commit.Records = lo.FilterMap(evt.Ops, func(op *comatproto.SyncSubscribeRepos_RepoOp, _ int) (RecordOp, bool) { if op == nil { - continue + return RecordOp{}, false } - collection, rkey, found := strings.Cut(op.Path, "/") - if !found { - log.Warn("commit op path has no rkey, dropping the op", "repo", evt.Repo, "path", op.Path) - continue - } - record := RecordOp{ - Action: op.Action, - Collection: collection, - Rkey: rkey, - } - if !record.Deleted() && repo != nil { - payload, _, err := repo.GetRecordBytes(ctx, syntax.NSID(collection), syntax.RecordKey(rkey)) - if err != nil { - log.Error("record bytes missing from the frame car, dropping the op", "repo", evt.Repo, "path", op.Path, "err", err) - continue - } - record.Bytes = payload + record, err := resolveOp(ctx, op, repo) + if err != nil { + log.Warn("dropping a commit op", "repo", repoDid, "path", op.Path, "err", err) + return RecordOp{}, false } - records = append(records, record) - } - commit.Records = records + return record, true + }) return commit, nil } +var recordActions = map[string]tapc.RecordAction{ + string(tapc.RecordCreateAction): tapc.RecordCreateAction, + string(tapc.RecordUpdateAction): tapc.RecordUpdateAction, + string(tapc.RecordDeleteAction): tapc.RecordDeleteAction, +} + +func resolveOp(ctx context.Context, op *comatproto.SyncSubscribeRepos_RepoOp, repo *atprotorepo.Repo) (RecordOp, error) { + action, known := recordActions[op.Action] + if !known { + return RecordOp{}, fmt.Errorf("op action %q is none of create, update or delete", op.Action) + } + rawCollection, rawRkey, found := strings.Cut(op.Path, "/") + if !found { + return RecordOp{}, errors.New("op path has no rkey") + } + collection, err := syntax.ParseNSID(rawCollection) + if err != nil { + return RecordOp{}, fmt.Errorf("op collection: %w", err) + } + rkey, err := syntax.ParseRecordKey(rawRkey) + if err != nil { + return RecordOp{}, fmt.Errorf("op rkey: %w", err) + } + record := RecordOp{Action: action, Collection: collection, Rkey: rkey} + if record.Deleted() || repo == nil { + return record, nil + } + payload, _, err := repo.GetRecordBytes(ctx, collection, rkey) + if err != nil { + return RecordOp{}, fmt.Errorf("record bytes missing from the frame car: %w", err) + } + record.Bytes = payload + return record, nil +} + const maxCborDepth = 64 func skipValue(r io.Reader, depth int) error { diff --git a/knotfeed/frame_test.go b/knotfeed/frame_test.go index 3702ce2e9..ff1c36022 100644 --- a/knotfeed/frame_test.go +++ b/knotfeed/frame_test.go @@ -110,7 +110,7 @@ func TestDecodeDropsUnresolvableOpsAndKeepsFrame(t *testing.T) { Seq: 43, Commit: lexutil.LexLink(testCid(t)), Ops: []*comatproto.SyncSubscribeRepos_RepoOp{ - {Action: "create", Path: GitRefCollection + "/refs~2fheads~2fmain"}, + {Action: "create", Path: GitRefCollection.String() + "/refs~2fheads~2fmain"}, }, } if err := evt.MarshalCBOR(&payload); err != nil { diff --git a/knotfeed/legacy.go b/knotfeed/legacy.go new file mode 100644 index 000000000..e437c824c --- /dev/null +++ b/knotfeed/legacy.go @@ -0,0 +1,131 @@ +package knotfeed + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/samber/lo" + "tangled.org/core/api/tangled" + "tangled.org/core/tapc" +) + +const ( + legacyRefUpdateNSID syntax.NSID = tangled.GitRefUpdateNSID + legacyCollaboratorUpdateNSID syntax.NSID = "sh.tangled.repo.collaboratorUpdate" + + collaboratorInviteCollection syntax.NSID = tangled.RepoCollaboratorInviteNSID +) + +type legacyAclOp string + +const ( + legacyAclOpAdd legacyAclOp = "add" + legacyAclOpRemove legacyAclOp = "remove" +) + +func (op legacyAclOp) action() tapc.RecordAction { + return lo.Ternary(strings.EqualFold(string(op), string(legacyAclOpRemove)), + tapc.RecordDeleteAction, tapc.RecordCreateAction) +} + +type legacyFrame struct { + Nsid syntax.NSID `json:"nsid"` + Created int64 `json:"created"` + Event json.RawMessage `json:"event"` +} + +type legacyRefUpdate struct { + Repo syntax.DID `json:"repo"` + Ref string `json:"ref"` + OldSha string `json:"oldSha"` + NewSha string `json:"newSha"` + CommitterDid syntax.DID `json:"committerDid"` + PushOptions []string `json:"pushOptions"` +} + +type legacyAclUpdate struct { + Op legacyAclOp `json:"op"` + Repo syntax.DID `json:"repo"` + Subject syntax.DID `json:"subject"` +} + +var legacyOps = map[syntax.NSID]func(json.RawMessage, *Commit) error{ + legacyRefUpdateNSID: legacyRefOp, + legacyCollaboratorUpdateNSID: legacyCollaboratorOp, +} + +func decodeLegacy(data []byte, _ *slog.Logger) (Message, error) { + var frame legacyFrame + if err := json.Unmarshal(data, &frame); err != nil { + return Message{}, fmt.Errorf("legacy frame: %w", err) + } + if frame.Created <= 0 { + return Message{}, fmt.Errorf("legacy %s frame lacks its created stamp: %d", frame.Nsid, frame.Created) + } + + commit := &Commit{Seq: frame.Created} + if op, known := legacyOps[frame.Nsid]; known { + if err := op(frame.Event, commit); err != nil { + return Message{}, err + } + } + return Message{Type: TypeCommit, Commit: commit}, nil +} + +func legacyRefOp(payload json.RawMessage, commit *Commit) error { + var event legacyRefUpdate + if err := json.Unmarshal(payload, &event); err != nil { + return fmt.Errorf("legacy ref update: %w", err) + } + rkey, short := EscapeRefname(event.Ref) + newSha, isObject := ParseObjectID(event.NewSha) + if event.Ref == "" || event.Repo == "" || !short || !isObject { + return nil + } + op := RecordOp{ + Action: tapc.RecordDeleteAction, + Collection: GitRefCollection, + Rkey: rkey, + Prior: ParsePriorSha(event.OldSha), + } + if !newSha.IsZero() { + record, err := encodeRefRecord(RefRecord{ + Sha: newSha, + Editor: event.CommitterDid, + PushOptions: event.PushOptions, + }) + if err != nil { + return fmt.Errorf("re-encoding legacy ref update: %w", err) + } + op.Action, op.Bytes = tapc.RecordCreateAction, record + } + + commit.Repo = event.Repo + commit.Records = []RecordOp{op} + return nil +} + +func legacyCollaboratorOp(payload json.RawMessage, commit *Commit) error { + var event legacyAclUpdate + if err := json.Unmarshal(payload, &event); err != nil { + return fmt.Errorf("legacy collaborator update: %w", err) + } + if event.Repo == "" || event.Subject == "" { + return nil + } + + rkey, err := syntax.ParseRecordKey(event.Subject.String()) + if err != nil { + return fmt.Errorf("legacy collaborator update names subject %q: %w", event.Subject, err) + } + commit.Repo = event.Repo + commit.Records = []RecordOp{{ + Action: event.Op.action(), + Collection: collaboratorInviteCollection, + Rkey: rkey, + }} + return nil +} diff --git a/knotfeed/legacy_test.go b/knotfeed/legacy_test.go new file mode 100644 index 000000000..f8fca9f9e --- /dev/null +++ b/knotfeed/legacy_test.go @@ -0,0 +1,248 @@ +package knotfeed + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "tangled.org/core/tapc" +) + +const ( + legacyNewSha = "bcfa6359b037bc411e9626260cc3dd9a28408d2c" + legacyOldSha = "9eab6c4f29bc9e609ec52888cd6a4ef716b567c9" + zeroSha1 = "0000000000000000000000000000000000000000" +) + +const legacyRefFrame = `{ + "rkey": "3mug", + "nsid": "sh.tangled.git.refUpdate", + "created": 1788245839553422000, + "event": { + "$type": "sh.tangled.git.refUpdate", + "ref": "refs/heads/iso16283-low-frequency-corner", + "oldSha": "9eab6c4f29bc9e609ec52888cd6a4ef716b567c9", + "newSha": "bcfa6359b037bc411e9626260cc3dd9a28408d2c", + "committerDid": "did:plc:jder4wkjnwh6mzhrx4n5ebti", + "ownerDid": "did:plc:jder4wkjnwh6mzhrx4n5ebti", + "repo": "did:plc:5i6o2hrw2viwpxgicwad7f2h", + "pushOptions": ["skip-ci"], + "changedFiles": ["tests/building/measurement/test_low_frequency.py"], + "meta": {"isDefaultRef": false, "commitCount": {"byEmail": [{"email": "mail@jmrp.io", "count": 1}]}} + } +}` + +func refFrame(ref, repo, oldSha, newSha string) string { + return fmt.Sprintf( + `{"nsid":"sh.tangled.git.refUpdate","created":11,`+ + `"event":{"ref":%q,"repo":%q,"oldSha":%q,"newSha":%q}}`, + ref, repo, oldSha, newSha) +} + +func sha(t *testing.T, hex string) ObjectID { + t.Helper() + id, ok := ParseObjectID(hex) + if !ok { + t.Fatalf("ParseObjectID(%q)", hex) + } + return id +} + +func refOp(t *testing.T, refname, oldSha string, rec RefRecord) RecordOp { + t.Helper() + rkey, ok := EscapeRefname(refname) + if !ok { + t.Fatalf("EscapeRefname(%q)", refname) + } + op := RecordOp{ + Action: tapc.RecordDeleteAction, + Collection: GitRefCollection, + Rkey: rkey, + Prior: ParsePriorSha(oldSha), + } + if rec.Sha.IsZero() { + return op + } + record, err := encodeRefRecord(rec) + if err != nil { + t.Fatalf("encodeRefRecord: %v", err) + } + op.Action, op.Bytes = tapc.RecordCreateAction, record + return op +} + +func TestLegacyFrameReadsAsCommit(t *testing.T) { + for _, tt := range []struct { + name string + frame string + want Commit + }{ + { + name: "push", + frame: legacyRefFrame, + want: Commit{ + Repo: "did:plc:5i6o2hrw2viwpxgicwad7f2h", + Seq: 1788245839553422000, + Records: []RecordOp{refOp(t, "refs/heads/iso16283-low-frequency-corner", legacyOldSha, + RefRecord{ + Sha: sha(t, legacyNewSha), + Editor: "did:plc:jder4wkjnwh6mzhrx4n5ebti", + PushOptions: []string{"skip-ci"}, + })}, + }, + }, + { + name: "deleted branch", + frame: refFrame("refs/heads/gone", "did:plc:scallop", legacyOldSha, zeroSha1), + want: Commit{ + Repo: "did:plc:scallop", + Seq: 11, + Records: []RecordOp{refOp(t, "refs/heads/gone", legacyOldSha, RefRecord{})}, + }, + }, + { + name: "deleted branch in a sha256 repo", + frame: refFrame("refs/heads/gone", "did:plc:scallop", + strings.Repeat("ab", 32), strings.Repeat("0", 64)), + want: Commit{ + Repo: "did:plc:scallop", + Seq: 11, + Records: []RecordOp{refOp(t, "refs/heads/gone", strings.Repeat("ab", 32), RefRecord{})}, + }, + }, + { + name: "brand new branch", + frame: refFrame("refs/heads/new", "did:plc:scallop", zeroSha1, legacyNewSha), + want: Commit{ + Repo: "did:plc:scallop", + Seq: 11, + Records: []RecordOp{refOp(t, "refs/heads/new", zeroSha1, + RefRecord{Sha: sha(t, legacyNewSha)})}, + }, + }, + { + name: "push that left oldSha empty", + frame: refFrame("refs/heads/new", "did:plc:scallop", "", legacyNewSha), + want: Commit{ + Repo: "did:plc:scallop", + Seq: 11, + Records: []RecordOp{refOp(t, "refs/heads/new", "", + RefRecord{Sha: sha(t, legacyNewSha)})}, + }, + }, + { + name: "collaborator removal", + frame: `{"nsid":"sh.tangled.repo.collaboratorUpdate","created":9, + "event":{"op":"remove","subject":"did:plc:olaren","repo":"did:plc:scallop"}}`, + want: Commit{ + Repo: "did:plc:scallop", + Seq: 9, + Records: []RecordOp{{ + Action: tapc.RecordDeleteAction, + Collection: collaboratorInviteCollection, + Rkey: "did:plc:olaren", + }}, + }, + }, + { + name: "knot member update", + frame: `{"nsid":"sh.tangled.knot.memberUpdate","created":11, + "event":{"op":"add","subject":"did:plc:limpet"}}`, + want: Commit{Seq: 11}, + }, + { + name: "ref update with an empty sha", + frame: refFrame("refs/heads/main", "did:plc:scallop", legacyOldSha, ""), + want: Commit{Seq: 11}, + }, + { + name: "ref update with an empty refname", + frame: refFrame("", "did:plc:scallop", legacyOldSha, legacyNewSha), + want: Commit{Seq: 11}, + }, + { + name: "new sha with shell junk in it", + frame: refFrame("refs/heads/main", "did:plc:scallop", legacyOldSha, "HEAD~1; rm -rf /"), + want: Commit{Seq: 11}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + msg, err := decodeLegacy([]byte(tt.frame), nil) + if err != nil { + t.Fatalf("decodeLegacy: %v", err) + } + if msg.Type != TypeCommit || msg.Commit == nil { + t.Fatalf("message type = %q, want a commit", msg.Type) + } + if !reflect.DeepEqual(*msg.Commit, tt.want) { + t.Errorf("commit = %+v, want %+v", *msg.Commit, tt.want) + } + }) + } +} + +func TestLegacyFrameWithoutCursorErrorsOut(t *testing.T) { + if _, err := decodeLegacy([]byte(`{"nsid":"sh.tangled.git.refUpdate"}`), nil); err == nil { + t.Error("frame without a created stamp decoded anyway") + } +} + +func TestOnlyObjectIdReadsAsPriorSha(t *testing.T) { + for _, reported := range []string{"", "def456", "HEAD", strings.Repeat("zz", 20), zeroSha1 + "0"} { + if id, known := ParsePriorSha(reported).Sha(); known { + t.Errorf("ParsePriorSha(%q) = %q, want an unknown prior", reported, id) + } + } + if _, known := (RecordOp{}).Prior.Sha(); known { + t.Error("zero-value RecordOp came back with a known prior sha") + } + + hex := strings.Repeat("ab", 32) + if got, known := ParsePriorSha(hex).Sha(); !known || got.String() != hex { + t.Errorf("ParsePriorSha(%q) = (%q, %v), want the sha back", hex, got, known) + } + if got, known := ParsePriorSha(zeroSha1).Sha(); !known || !got.IsZero() { + t.Errorf("ParsePriorSha(%q) = (%q, %v), want a known absence", zeroSha1, got, known) + } +} + +func TestParseObjectIDTakesOnlyRealObjectIds(t *testing.T) { + for _, hex := range []string{ + "", "def456", strings.Repeat("ab", 21), strings.Repeat("zz", 20), "HEAD~1; rm -rf /", + } { + if id, ok := ParseObjectID(hex); ok { + t.Errorf("ParseObjectID(%q) = %q, want a refusal", hex, id) + } + } + for _, hex := range []string{zeroSha1, strings.Repeat("0", 64)} { + id, ok := ParseObjectID(hex) + if !ok || !id.IsZero() { + t.Errorf("ParseObjectID(%q) = (%q, %v), want the null object id", hex, id, ok) + } + } + for _, hex := range []string{legacyNewSha, strings.Repeat("AB", 20), strings.Repeat("cD", 32)} { + id, ok := ParseObjectID(hex) + if !ok || id.String() != hex { + t.Errorf("ParseObjectID(%q) = (%q, %v), want the sha back", hex, id, ok) + } + } +} + +func TestLegacyFrameWithUnusableFieldsIsRefused(t *testing.T) { + for _, tt := range []struct { + name string + frame string + }{ + {"nsid that isn't an nsid", `{"nsid":"not an nsid","created":11}`}, + {"repo that isn't a did", refFrame("refs/heads/main", "scallop", legacyOldSha, legacyNewSha)}, + {"collaborator subject that isn't a did", `{"nsid":"sh.tangled.repo.collaboratorUpdate","created":9, + "event":{"op":"remove","subject":"olaren","repo":"did:plc:scallop"}}`}, + } { + t.Run(tt.name, func(t *testing.T) { + if _, err := decodeLegacy([]byte(tt.frame), nil); err == nil { + t.Error("frame decoded anyway") + } + }) + } +} diff --git a/knotfeed/record.go b/knotfeed/record.go index 69a6c3fd1..663d51fca 100644 --- a/knotfeed/record.go +++ b/knotfeed/record.go @@ -3,14 +3,18 @@ package knotfeed import ( "bytes" "fmt" + "slices" "strings" "unicode/utf8" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/samber/lo" cbg "github.com/whyrusleeping/cbor-gen" + "tangled.org/core/api/tangled" ) const maxPushOptions = 64 -const GitRefCollection = "sh.tangled.git.ref" +const GitRefCollection syntax.NSID = tangled.GitRefNSID const mstKeyBudget = 256 @@ -38,7 +42,7 @@ func allPlain(s string) bool { return true } -func EscapeRefname(refname string) (string, bool) { +func EscapeRefname(refname string) (syntax.RecordKey, bool) { out := make([]byte, 0, len(refname)) for i := range len(refname) { b := refname[i] @@ -48,19 +52,19 @@ func EscapeRefname(refname string) (string, bool) { } out = append(out, '~', hexDigits[b>>4], hexDigits[b&0x0f]) } - rkey := string(out) - if len(rkey) > MaxRefRkeyBytes { + if len(out) > MaxRefRkeyBytes { return "", false } - return rkey, true + return syntax.RecordKey(out), true } -func UnescapeRkey(rkey string) (string, bool) { - if !strings.Contains(rkey, "~") { - return rkey, allPlain(rkey) +func UnescapeRkey(rkey syntax.RecordKey) (string, bool) { + escaped := rkey.String() + if !strings.Contains(escaped, "~") { + return escaped, allPlain(escaped) } var out []byte - rest := rkey + rest := escaped for { idx := strings.IndexByte(rest, '~') if idx < 0 { @@ -102,13 +106,14 @@ func nibble(b byte) (byte, bool) { } type RefRecord struct { - Sha string - Editor string + Sha ObjectID + Editor syntax.DID PushOptions []string } func DecodeRefRecord(data []byte) (RefRecord, error) { var rec RefRecord + var rawSha, rawEditor string r := bytes.NewReader(data) maj, count, err := cbg.CborReadHeader(r) if err != nil { @@ -124,11 +129,11 @@ func DecodeRefRecord(data []byte) (RefRecord, error) { } switch key { case "sha": - if rec.Sha, err = cbg.ReadString(r); err != nil { + if rawSha, err = cbg.ReadString(r); err != nil { return rec, err } case "x-tngl-editor": - if rec.Editor, err = cbg.ReadString(r); err != nil { + if rawEditor, err = cbg.ReadString(r); err != nil { return rec, err } case "x-tngl-push-options": @@ -141,9 +146,22 @@ func DecodeRefRecord(data []byte) (RefRecord, error) { } } } - if rec.Sha == "" { - return rec, fmt.Errorf("ref record is missing its sha") + if r.Len() != 0 { + return rec, fmt.Errorf("ref record has %d trailing bytes after its map", r.Len()) } + sha, ok := ParseObjectID(rawSha) + if !ok || sha.IsZero() { + return rec, fmt.Errorf("sha %q isn't 40 or 64 hex digits naming an object", rawSha) + } + rec.Sha = sha + if rawEditor == "" { + return rec, nil + } + editor, err := syntax.ParseDID(rawEditor) + if err != nil { + return rec, fmt.Errorf("ref record editor: %w", err) + } + rec.Editor = editor return rec, nil } @@ -168,3 +186,53 @@ func readStringArray(r *bytes.Reader) ([]string, error) { } return out, nil } + +type cborBuf struct{ bytes.Buffer } + +func (b *cborBuf) header(major byte, count uint64) { + _ = cbg.CborWriteHeader(&b.Buffer, major, count) +} + +func (b *cborBuf) str(s string) { + b.header(cbg.MajTextString, uint64(len(s))) + b.WriteString(s) +} + +func (b *cborBuf) pair(key, value string) { + b.str(key) + b.str(value) +} + +func encodeRefRecord(rec RefRecord) ([]byte, error) { + past := func(s string) bool { return len(s) > cbg.MaxLength } + switch { + case rec.Sha.IsZero(): + return nil, fmt.Errorf("won't encode a ref record without a sha") + case len(rec.PushOptions) > maxPushOptions: + return nil, fmt.Errorf("won't encode %d push options, past the %d limit", len(rec.PushOptions), maxPushOptions) + case past(rec.Editor.String()) || slices.ContainsFunc(rec.PushOptions, past): + return nil, fmt.Errorf("ref record holds a string past the %d byte cbor limit", cbg.MaxLength) + } + + pairs := uint64(2) + if rec.Editor != "" { + pairs++ + } + if len(rec.PushOptions) > 0 { + pairs++ + } + + var out cborBuf + out.header(cbg.MajMap, pairs) + out.pair("sha", rec.Sha.String()) + out.pair("$type", GitRefCollection.String()) + if rec.Editor != "" { + out.pair("x-tngl-editor", rec.Editor.String()) + } + if len(rec.PushOptions) > 0 { + out.str("x-tngl-push-options") + out.header(cbg.MajArray, uint64(len(rec.PushOptions))) + lo.ForEach(rec.PushOptions, func(option string, _ int) { out.str(option) }) + } + return out.Bytes(), nil +} diff --git a/knotfeed/record_test.go b/knotfeed/record_test.go index 2ece8b69b..430f0d901 100644 --- a/knotfeed/record_test.go +++ b/knotfeed/record_test.go @@ -7,23 +7,24 @@ import ( "strings" "testing" + "github.com/bluesky-social/indigo/atproto/syntax" cbg "github.com/whyrusleeping/cbor-gen" ) const frozenPushedHex = "a4637368617828616261626162616261626162616261626162616261626162616261626162616261626162616261626524747970657273682e74616e676c65642e6769742e7265666d782d746e676c2d656469746f726e6469643a706c633a6c696d70657473782d746e676c2d707573682d6f7074696f6e738167736b69702d6369" -const frozenBareHex = "a2637368617840636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646524747970657273682e74616e676c65642e6769742e726566" +const frozenBareHex = "a2637368617840636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646524747970657273682e74616e676c65642e6769742e726566" -func TestDecodeRefRecordReadsTheFrozenRecordBytes(t *testing.T) { +func TestFrozenRecordBytesDecodeAndEncodeBackAsThemselves(t *testing.T) { for _, tt := range []struct { - name string - hex string - wantSha string - wantEditor string - wantOptions []string + name string + hex string + want RefRecord }{ - {"pushed", frozenPushedHex, strings.Repeat("ab", 20), "did:plc:limpet", []string{"skip-ci"}}, - {"catch-up", frozenBareHex, strings.Repeat("cd", 32), "", nil}, + {"pushed", frozenPushedHex, RefRecord{ + Sha: sha(t, strings.Repeat("ab", 20)), Editor: "did:plc:limpet", PushOptions: []string{"skip-ci"}, + }}, + {"catch-up", frozenBareHex, RefRecord{Sha: sha(t, strings.Repeat("cd", 32))}}, } { t.Run(tt.name, func(t *testing.T) { data, err := hex.DecodeString(tt.hex) @@ -34,34 +35,80 @@ func TestDecodeRefRecordReadsTheFrozenRecordBytes(t *testing.T) { if err != nil { t.Fatalf("DecodeRefRecord: %v", err) } - if rec.Sha != tt.wantSha { - t.Fatalf("Sha = %q, want %q", rec.Sha, tt.wantSha) + if rec.Sha != tt.want.Sha || rec.Editor != tt.want.Editor || + !slices.Equal(rec.PushOptions, tt.want.PushOptions) { + t.Fatalf("record = %+v, want %+v", rec, tt.want) } - if rec.Editor != tt.wantEditor { - t.Fatalf("Editor = %q, want %q", rec.Editor, tt.wantEditor) + again, err := encodeRefRecord(tt.want) + if err != nil { + t.Fatalf("encodeRefRecord: %v", err) } - if !slices.Equal(rec.PushOptions, tt.wantOptions) { - t.Fatalf("PushOptions = %v, want %v", rec.PushOptions, tt.wantOptions) + if !bytes.Equal(again, data) { + t.Errorf("re-encoded record\n got %x\nwant %x", again, data) } }) } } -func TestDecodeRefRecordRefusesMalformedRecords(t *testing.T) { +func shaRecord(t *testing.T, reported string) []byte { + t.Helper() var out bytes.Buffer if err := cbg.CborWriteHeader(&out, cbg.MajMap, 1); err != nil { t.Fatalf("map header: %v", err) } - writeText(&out, "x-tngl-push-options") - if err := cbg.CborWriteHeader(&out, cbg.MajArray, 1<<32); err != nil { + writeText(&out, "sha") + writeText(&out, reported) + return out.Bytes() +} + +func TestDecodeRefRecordRefusesMalformedRecords(t *testing.T) { + const overlongShaRun = "a2637368617840636463646364636463646364636463646364636463646364636463" + + "64636463646364636463646364636463646364636463646364636463646364636463646364636463" + + "646524747970657273682e74616e676c65642e6769742e726566" + + overlong, err := hex.DecodeString(overlongShaRun) + if err != nil { + t.Fatalf("decoding overlong record: %v", err) + } + var hugeArray bytes.Buffer + if err := cbg.CborWriteHeader(&hugeArray, cbg.MajMap, 1); err != nil { + t.Fatalf("map header: %v", err) + } + writeText(&hugeArray, "x-tngl-push-options") + if err := cbg.CborWriteHeader(&hugeArray, cbg.MajArray, 1<<32); err != nil { t.Fatalf("array header: %v", err) } - for _, data := range [][]byte{ - {0xa1, 0x63, 'a', 'b', 0x00}, - out.Bytes(), - } { + + refused := [][]byte{{0xa1, 0x63, 'a', 'b', 0x00}, hugeArray.Bytes(), overlong} + for _, reported := range []string{"", "def456", strings.Repeat("ab", 21), strings.Repeat("zz", 20)} { + refused = append(refused, shaRecord(t, reported)) + } + for _, data := range refused { if _, err := DecodeRefRecord(data); err == nil { - t.Fatalf("DecodeRefRecord accepted % x", data) + t.Errorf("DecodeRefRecord accepted % x", data) + } + } + + for _, reported := range []string{strings.Repeat("ab", 20), strings.Repeat("AB", 32)} { + if _, err := DecodeRefRecord(shaRecord(t, reported)); err != nil { + t.Errorf("DecodeRefRecord(%q): %v", reported, err) + } + } +} + +func TestEncodeRefRecordRefusesWhatItsOwnDecoderWouldReject(t *testing.T) { + present := sha(t, strings.Repeat("ab", 20)) + huge := strings.Repeat("x", cbg.MaxLength+1) + for _, tt := range []struct { + name string + rec RefRecord + }{ + {"no sha at all", RefRecord{}}, + {"editor past the cbor limit", RefRecord{Sha: present, Editor: syntax.DID(huge)}}, + {"push option past the cbor limit", RefRecord{Sha: present, PushOptions: []string{huge}}}, + } { + if _, err := encodeRefRecord(tt.rec); err == nil { + t.Errorf("%s encoded anyway, and the record would fail its own decode", tt.name) } } } @@ -69,12 +116,12 @@ func TestDecodeRefRecordRefusesMalformedRecords(t *testing.T) { func TestEscapeRefnameRulesTheRkey(t *testing.T) { for _, tt := range []struct { refname string - want string + want syntax.RecordKey wantOK bool }{ {"refs/heads/main", "refs~2fheads~2fmain", true}, {"refs/heads/~x", "refs~2fheads~2f~7ex", true}, - {strings.Repeat("/", 79), strings.Repeat("~2f", 79), true}, + {strings.Repeat("/", 79), syntax.RecordKey(strings.Repeat("~2f", 79)), true}, {strings.Repeat("/", 80), "", false}, } { rkey, ok := EscapeRefname(tt.refname) @@ -110,7 +157,7 @@ func TestUnescapeRkeyRoundTripsPlainAndEscapedRefnames(t *testing.T) { } func TestUnescapeRkeyRefusesMalformedKeys(t *testing.T) { - for _, rkey := range []string{ + for _, rkey := range []syntax.RecordKey{ "refs~2fheads~", "refs~2fheads~2", "refs~2fheads~zz",