From c89a664a37ed468af28598328522c1769e6176cb Mon Sep 17 00:00:00 2001 From: pptx704 Date: Fri, 27 Mar 2026 00:53:51 +0600 Subject: [PATCH] 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 --- internal/id/id.go | 47 ++++++++++++++-- internal/id/id_test.go | 118 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 internal/id/id_test.go diff --git a/internal/id/id.go b/internal/id/id.go index c27869a..35c44ae 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -4,12 +4,20 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "math/big" "strings" "github.com/google/uuid" "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 --- // newUUID returns a new random (v4) UUID wrapped in pgtype.UUID for direct DB use. @@ -68,8 +76,41 @@ const ( 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 { - return prefix + uuid.UUID(id.Bytes).String() + return prefix + uuidToBase36(id.Bytes) } 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) { 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 { 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) } diff --git a/internal/id/id_test.go b/internal/id/id_test.go new file mode 100644 index 0000000..f8ae285 --- /dev/null +++ b/internal/id/id_test.go @@ -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) + } +}