forked from wrenn/wrenn
Switch API ID format from UUID to base36 for compact, E2B-style IDs
DB stays native UUID; the format/parse layer now encodes 16 UUID bytes as 25-char lowercase alphanumeric (base36) strings instead of the standard 36-char hex-with-dashes format. e.g. sb-2e5glxi4g3qnhwci95qev0cg0
This commit is contained in:
@ -4,12 +4,20 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
base36Alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||||
|
base36IDLen = 25 // ceil(128 * log2 / log36) = 25 chars for a full UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
var base36Base = big.NewInt(36)
|
||||||
|
|
||||||
// --- Generation ---
|
// --- Generation ---
|
||||||
|
|
||||||
// newUUID returns a new random (v4) UUID wrapped in pgtype.UUID for direct DB use.
|
// newUUID returns a new random (v4) UUID wrapped in pgtype.UUID for direct DB use.
|
||||||
@ -68,8 +76,41 @@ const (
|
|||||||
PrefixAdminPermission = "perm-"
|
PrefixAdminPermission = "perm-"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// uuidToBase36 encodes 16 UUID bytes as a 25-char base36 string (0-9a-z).
|
||||||
|
func uuidToBase36(b [16]byte) string {
|
||||||
|
n := new(big.Int).SetBytes(b[:])
|
||||||
|
buf := make([]byte, base36IDLen)
|
||||||
|
mod := new(big.Int)
|
||||||
|
for i := base36IDLen - 1; i >= 0; i-- {
|
||||||
|
n.DivMod(n, base36Base, mod)
|
||||||
|
buf[i] = base36Alphabet[mod.Int64()]
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// base36ToUUID decodes a 25-char base36 string back to 16 UUID bytes.
|
||||||
|
func base36ToUUID(s string) ([16]byte, error) {
|
||||||
|
if len(s) != base36IDLen {
|
||||||
|
return [16]byte{}, fmt.Errorf("expected %d-char base36 ID, got %d", base36IDLen, len(s))
|
||||||
|
}
|
||||||
|
n := new(big.Int)
|
||||||
|
for _, c := range s {
|
||||||
|
idx := strings.IndexRune(base36Alphabet, c)
|
||||||
|
if idx < 0 {
|
||||||
|
return [16]byte{}, fmt.Errorf("invalid base36 character: %c", c)
|
||||||
|
}
|
||||||
|
n.Mul(n, base36Base)
|
||||||
|
n.Add(n, big.NewInt(int64(idx)))
|
||||||
|
}
|
||||||
|
b := n.Bytes()
|
||||||
|
var out [16]byte
|
||||||
|
// big.Int.Bytes() strips leading zeros; right-align into 16-byte array.
|
||||||
|
copy(out[16-len(b):], b)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func formatUUID(prefix string, id pgtype.UUID) string {
|
func formatUUID(prefix string, id pgtype.UUID) string {
|
||||||
return prefix + uuid.UUID(id.Bytes).String()
|
return prefix + uuidToBase36(id.Bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatSandboxID(id pgtype.UUID) string { return formatUUID(PrefixSandbox, id) }
|
func FormatSandboxID(id pgtype.UUID) string { return formatUUID(PrefixSandbox, id) }
|
||||||
@ -88,11 +129,11 @@ func parseUUID(prefix, s string) (pgtype.UUID, error) {
|
|||||||
if !strings.HasPrefix(s, prefix) {
|
if !strings.HasPrefix(s, prefix) {
|
||||||
return pgtype.UUID{}, fmt.Errorf("invalid ID: expected %q prefix, got %q", prefix, s)
|
return pgtype.UUID{}, fmt.Errorf("invalid ID: expected %q prefix, got %q", prefix, s)
|
||||||
}
|
}
|
||||||
u, err := uuid.Parse(strings.TrimPrefix(s, prefix))
|
b, err := base36ToUUID(strings.TrimPrefix(s, prefix))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return pgtype.UUID{}, fmt.Errorf("invalid ID %q: %w", s, err)
|
return pgtype.UUID{}, fmt.Errorf("invalid ID %q: %w", s, err)
|
||||||
}
|
}
|
||||||
return pgtype.UUID{Bytes: u, Valid: true}, nil
|
return pgtype.UUID{Bytes: b, Valid: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseSandboxID(s string) (pgtype.UUID, error) { return parseUUID(PrefixSandbox, s) }
|
func ParseSandboxID(s string) (pgtype.UUID, error) { return parseUUID(PrefixSandbox, s) }
|
||||||
|
|||||||
118
internal/id/id_test.go
Normal file
118
internal/id/id_test.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
package id
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBase36RoundTrip(t *testing.T) {
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
orig := uuid.New()
|
||||||
|
encoded := uuidToBase36(orig)
|
||||||
|
|
||||||
|
if len(encoded) != base36IDLen {
|
||||||
|
t.Fatalf("expected %d chars, got %d: %s", base36IDLen, len(encoded), encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base36ToUUID(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded != orig {
|
||||||
|
t.Fatalf("round-trip failed: %v → %s → %v", orig, encoded, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBase36ZeroUUID(t *testing.T) {
|
||||||
|
var zero [16]byte
|
||||||
|
encoded := uuidToBase36(zero)
|
||||||
|
if encoded != "0000000000000000000000000" {
|
||||||
|
t.Fatalf("zero UUID should encode to all zeros, got %s", encoded)
|
||||||
|
}
|
||||||
|
decoded, err := base36ToUUID(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if decoded != zero {
|
||||||
|
t.Fatalf("round-trip failed for zero UUID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatParseRoundTrip(t *testing.T) {
|
||||||
|
id := NewSandboxID()
|
||||||
|
formatted := FormatSandboxID(id)
|
||||||
|
|
||||||
|
if formatted[:3] != "sb-" {
|
||||||
|
t.Fatalf("expected sb- prefix, got %s", formatted)
|
||||||
|
}
|
||||||
|
if len(formatted) != 3+base36IDLen {
|
||||||
|
t.Fatalf("expected %d chars total, got %d: %s", 3+base36IDLen, len(formatted), formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ParseSandboxID(formatted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if parsed != id {
|
||||||
|
t.Fatalf("round-trip failed: %v → %s → %v", id, formatted, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBase36InvalidInput(t *testing.T) {
|
||||||
|
// Wrong length.
|
||||||
|
if _, err := base36ToUUID("abc"); err == nil {
|
||||||
|
t.Fatal("expected error for short input")
|
||||||
|
}
|
||||||
|
// Invalid character.
|
||||||
|
if _, err := base36ToUUID("000000000000000000000000!"); err == nil {
|
||||||
|
t.Fatal("expected error for invalid character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformTeamIDFormats(t *testing.T) {
|
||||||
|
formatted := FormatTeamID(PlatformTeamID)
|
||||||
|
parsed, err := ParseTeamID(formatted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if parsed != PlatformTeamID {
|
||||||
|
t.Fatalf("platform team ID round-trip failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxUUID(t *testing.T) {
|
||||||
|
max := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
|
||||||
|
encoded := uuidToBase36(max)
|
||||||
|
if len(encoded) != base36IDLen {
|
||||||
|
t.Fatalf("max UUID encoding wrong length: %d", len(encoded))
|
||||||
|
}
|
||||||
|
decoded, err := base36ToUUID(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if decoded != max {
|
||||||
|
t.Fatalf("round-trip failed for max UUID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkFormatSandboxID(b *testing.B) {
|
||||||
|
id := pgtype.UUID{Bytes: uuid.New(), Valid: true}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
FormatSandboxID(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseSandboxID(b *testing.B) {
|
||||||
|
id := pgtype.UUID{Bytes: uuid.New(), Valid: true}
|
||||||
|
s := FormatSandboxID(id)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
ParseSandboxID(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user