diff options
Diffstat (limited to 'cmd/mirumd/id.go')
| -rw-r--r-- | cmd/mirumd/id.go | 302 | +302 −0 |
1 files changed, 302 insertions, 0 deletions
diff --git a/cmd/mirumd/id.go b/cmd/mirumd/id.go new file mode 100644 --- /dev/null +++ b/cmd/mirumd/id.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "database/sql/driver" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/google/uuid" +) + +var ( + ErrBadID = errors.New("database: bad id") + ErrBadIDPrefix = errors.New("database: wrong id prefix") +) + +type ( + OrgID = ID[OrgKind] + UserID = ID[UserKind] + WorkerID = ID[WorkerKind] +) + +// IDKind is a phantom-type tag that distinguishes otherwise-identical +// 16-byte IDs at the Go type level. Each tag is a zero-sized struct +// that carries only its 3-letter prefix. +type IDKind interface { + UserKind | OrgKind | WorkerKind + Prefix() string +} + +type ( + UserKind struct{} + OrgKind struct{} + WorkerKind struct{} +) + +func (OrgKind) Prefix() string { return "org" } +func (UserKind) Prefix() string { return "usr" } +func (WorkerKind) Prefix() string { return "wrk" } + +// ID[K] is a typed UUIDv7. Instantiations with different K are distinct +// types, so passing a UserID where an OrgID is expected is a compile +// error. Cross-kind conversion requires an explicit cast, visible in +// review. +type ID[K IDKind] uuid.UUID + +func NewID[K IDKind]() ID[K] { + return ID[K](uuid.Must(uuid.NewV7())) +} + +func IDFromBytes[K IDKind](b []byte) (ID[K], error) { + var zero ID[K] + if len(b) != 16 { + return zero, fmt.Errorf("%w: got %d bytes", ErrBadID, len(b)) + } + u := uuid.UUID(b) + if err := validateV7(u); err != nil { + return zero, err + } + return ID[K](u), nil +} + +func validateV7(u uuid.UUID) error { + if v := u.Variant(); v != uuid.RFC4122 { + return fmt.Errorf("%w: variant %s", ErrBadID, v) + } + if v := u.Version(); v != 7 { + return fmt.Errorf("%w: version %d, want 7", ErrBadID, v) + } + return nil +} + +func (id ID[K]) UUID() uuid.UUID { return uuid.UUID(id) } +func (id ID[K]) Bytes() []byte { return id[:] } + +func (id ID[K]) IsZero() bool { + var zero ID[K] + return id == zero +} + +// String returns the prefixed form for logs, JSON, errors and anywhere +// the entity type isn't obvious from context. For URL path segments +// where the route already names the type, use Bare(). +func (id ID[K]) String() string { + var k K + return k.Prefix() + "_" + encodeBase58(uuid.UUID(id)) +} + +// Bare returns the base58 form without a type prefix — ≤ 22 chars. +// Use this for URL path segments where the route already identifies +// the entity ("/org/:id"); use String() everywhere else. +func (id ID[K]) Bare() string { + return encodeBase58(uuid.UUID(id)) +} + +func (id ID[K]) LogValue() slog.Value { + return slog.StringValue(id.String()) +} + +func (id ID[K]) MarshalText() ([]byte, error) { + return []byte(id.String()), nil +} + +func (id *ID[K]) UnmarshalText(b []byte) error { + parsed, err := ParseID[K](string(b)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id *ID[K]) Scan(src any) error { + var u uuid.UUID + if err := u.Scan(src); err != nil { + return err + } + *id = ID[K](u) + return nil +} + +func (id ID[K]) Value() (driver.Value, error) { + return uuid.UUID(id).Value() +} + +// ParseID accepts the prefixed base58 ("<prefix>_<b58>"), bare base58 +// (≤ 22 chars, as returned by Bare()), or a canonical UUID string. +// Bare forms stay supported so existing CLI flags, manual SQL lookups +// and URL path parameters keep working without a flag day. +func ParseID[K IDKind](s string) (ID[K], error) { + var k K + u, err := parseID(k.Prefix(), s) + return ID[K](u), err +} + +func MustParseID[K IDKind](s string) ID[K] { + id, err := ParseID[K](s) + if err != nil { + panic(err) + } + return id +} + +// ParseAnyID parses a prefixed, bare base58, or canonical UUID string +// into raw 16 bytes. Unlike ParseID[K], it does not require a known +// entity kind — any 3-letter prefix is stripped silently. Intended for +// CLI boundary code that dispatches on proto field names. +func ParseAnyID(s string) ([16]byte, error) { + if s == "" { + return [16]byte{}, fmt.Errorf("%w: empty", ErrBadID) + } + // Strip any typed prefix. + if len(s) > 4 && s[3] == '_' { + s = s[4:] + } + if len(s) <= 22 { + return decodeBase58(s) + } + u, err := uuid.Parse(s) + if err != nil { + return [16]byte{}, fmt.Errorf("%w: %w", ErrBadID, err) + } + return u, nil +} + +// FormatAnyID formats raw 16-byte ID as bare base58. Returns base64 for +// non-16-byte inputs as a fallback. +func FormatAnyID(b []byte) string { + if len(b) == 16 { + return encodeBase58(uuid.UUID(b)) + } + return fmt.Sprintf("%x", b) +} + +func parseID(prefix, s string) (uuid.UUID, error) { + if s == "" { + return uuid.Nil, fmt.Errorf("%w: empty", ErrBadID) + } + + if rest, ok := strings.CutPrefix(s, prefix+"_"); ok { + return decodeBase58(rest) + } + + // Typed prefix with the wrong value — reject so a cross-type paste + // doesn't silently fall through to the bare path. + if len(s) > 4 && s[3] == '_' { + return uuid.Nil, fmt.Errorf("%w: want %q, got %q", ErrBadIDPrefix, prefix, s[:3]) + } + + // Bare form: base58 (≤ 22 chars, from Bare()) or canonical UUID + // (32–36 chars, from manual SQL / legacy CLI). + if len(s) <= 22 { + return decodeBase58(s) + } + + u, err := uuid.Parse(s) + if err != nil { + return uuid.Nil, fmt.Errorf("%w: %w", ErrBadID, err) + } + + return u, nil +} + +// --- base58 (Bitcoin alphabet) --- +// +// 16 bytes fit in ≤ 22 base58 chars: log₅₈(2¹²⁸) ≈ 21.86. +// UUIDv7 values in the post-1970 range always have a non-zero +// leading byte, so encoded length is effectively a constant 21-22. + +const b58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +var b58Index [256]byte + +func init() { + for i := range b58Index { + b58Index[i] = 0xff + } + for i := 0; i < len(b58Alphabet); i++ { + b58Index[b58Alphabet[i]] = byte(i) + } +} + +func encodeBase58(src uuid.UUID) string { + zeros := 0 + for zeros < 16 && src[zeros] == 0 { + zeros++ + } + + buf := src // array copy; long-division is in place + start := zeros + out := make([]byte, 0, 22) + + for start < 16 { + rem := 0 + for i := start; i < 16; i++ { + v := rem*256 + int(buf[i]) + buf[i] = byte(v / 58) + rem = v % 58 + } + out = append(out, b58Alphabet[rem]) + for start < 16 && buf[start] == 0 { + start++ + } + } + + for i := 0; i < zeros; i++ { + out = append(out, b58Alphabet[0]) + } + + // Reverse into big-endian order. + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + + return string(out) +} + +// decodeBase58 is strict about length — any input that round-trips to a +// value of a different byte length is rejected; we never want a +// 15-byte or 17-byte payload masquerading as a UUID. +func decodeBase58(s string) (uuid.UUID, error) { + if s == "" { + return uuid.Nil, fmt.Errorf("%w: empty", ErrBadID) + } + zeros := 0 + for zeros < len(s) && s[zeros] == b58Alphabet[0] { + zeros++ + } + + var out uuid.UUID + for i := zeros; i < len(s); i++ { + v := b58Index[s[i]] + if v == 0xff { + return uuid.Nil, fmt.Errorf("%w: bad base58 char %q", ErrBadID, s[i]) + } + carry := int(v) + for j := 15; j >= 0; j-- { + acc := int(out[j])*58 + carry + out[j] = byte(acc) + carry = acc >> 8 + } + if carry != 0 { + return uuid.Nil, fmt.Errorf("%w: overflow", ErrBadID) + } + } + + // The '1' prefix count in the string must equal the leading-zero + // byte count in the result — any mismatch means the input decoded + // to a different byte length than a UUID. + actualZeros := 0 + for actualZeros < 16 && out[actualZeros] == 0 { + actualZeros++ + } + if actualZeros != zeros { + return uuid.Nil, fmt.Errorf("%w: length mismatch", ErrBadID) + } + return out, nil +} |
