package knotfeed
import (
"bytes"
"fmt"
"io"
"slices"
"strings"
"unicode/utf8"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/samber/lo"
cbg "github.com/whyrusleeping/cbor-gen"
)
const maxPushOptions = 64
const GitRefCollection syntax.NSID = "sh.tangled.git.ref"
const mstKeyBudget = 256
const MaxRefRkeyBytes = mstKeyBudget - len(GitRefCollection) - 1
const hexDigits = "0123456789abcdef"
func plainByte(b byte) bool {
switch {
case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':
return true
case b == '-', b == '_':
return true
default:
return false
}
}
func allPlain(s string) bool {
for i := range len(s) {
if !plainByte(s[i]) {
return false
}
}
return true
}
func EscapeRefname(refname string) (syntax.RecordKey, bool) {
out := make([]byte, 0, len(refname))
for i := range len(refname) {
b := refname[i]
if plainByte(b) {
out = append(out, b)
continue
}
out = append(out, '~', hexDigits[b>>4], hexDigits[b&0x0f])
}
if len(out) > MaxRefRkeyBytes {
return "", false
}
return syntax.RecordKey(out), true
}
func UnescapeRkey(rkey syntax.RecordKey) (string, bool) {
escaped := rkey.String()
if !strings.Contains(escaped, "~") {
return escaped, allPlain(escaped)
}
var out []byte
rest := escaped
for {
idx := strings.IndexByte(rest, '~')
if idx < 0 {
if !allPlain(rest) {
return "", false
}
if !utf8.Valid(out) {
return "", false
}
return string(append(out, rest...)), true
}
head, chunk := rest[:idx], rest[idx+1:]
if !allPlain(head) || len(chunk) < 2 {
return "", false
}
hi, ok := nibble(chunk[0])
if !ok {
return "", false
}
lo, ok := nibble(chunk[1])
if !ok {
return "", false
}
out = append(out, head...)
out = append(out, hi<<4|lo)
rest = chunk[2:]
}
}
func nibble(b byte) (byte, bool) {
switch {
case b >= '0' && b <= '9':
return b - '0', true
case b >= 'a' && b <= 'f':
return b - 'a' + 10, true
default:
return 0, false
}
}
type RefRecord struct {
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 {
return rec, err
}
if maj != cbg.MajMap {
return rec, fmt.Errorf("expected a cbor map, got major type %d", maj)
}
for range count {
key, err := cbg.ReadString(r)
if err != nil {
return rec, err
}
switch key {
case "sha":
if rawSha, err = cbg.ReadString(r); err != nil {
return rec, err
}
case "x-tngl-editor":
if rawEditor, err = cbg.ReadString(r); err != nil {
return rec, err
}
case "x-tngl-push-options":
if rec.PushOptions, err = readStringArray(r); err != nil {
return rec, err
}
default:
if err := skipValue(r, 0); err != nil {
return rec, err
}
}
}
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
}
func readStringArray(r *bytes.Reader) ([]string, error) {
maj, count, err := cbg.CborReadHeader(r)
if err != nil {
return nil, err
}
if maj != cbg.MajArray {
return nil, fmt.Errorf("expected a cbor array, got major type %d", maj)
}
if count > maxPushOptions {
return nil, fmt.Errorf("push options array claims %d entries", count)
}
out := make([]string, 0, count)
for range count {
s, err := cbg.ReadString(r)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, nil
}
func DecodeRefsSnapshot(data []byte) (defaultBranch string, packedRefs []byte, err error) {
r := bytes.NewReader(data)
maj, count, err := cbg.CborReadHeader(r)
if err != nil {
return "", nil, err
}
if maj != cbg.MajMap {
return "", nil, fmt.Errorf("expected a cbor map, got major type %d", maj)
}
var sawBranch, sawRefs bool
for range count {
key, err := cbg.ReadString(r)
if err != nil {
return "", nil, err
}
switch key {
case "defaultBranch":
if defaultBranch, err = cbg.ReadString(r); err != nil {
return "", nil, err
}
sawBranch = true
case "refs":
maj, n, err := cbg.CborReadHeader(r)
if err != nil {
return "", nil, err
}
if maj != cbg.MajByteString {
return "", nil, fmt.Errorf("refs should be bytes, got major type %d", maj)
}
packedRefs = make([]byte, n)
if _, err := io.ReadFull(r, packedRefs); err != nil {
return "", nil, err
}
sawRefs = true
default:
var skipped cbg.Deferred
if err := skipped.UnmarshalCBOR(r); err != nil {
return "", nil, err
}
}
}
if !sawBranch {
return "", nil, fmt.Errorf("refs snapshot is missing defaultBranch")
}
if !sawRefs {
return "", nil, fmt.Errorf("refs snapshot is missing refs")
}
return defaultBranch, packedRefs, 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
}