feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr: - Add New() constructor, StatusFor() HTTP-status mapping - Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors - Add chi-compatible Middleware for panic(*Error) → JSON recovery - Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware) idgen: - Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0) - Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes) - Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check) codes: - Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen - All existing codes generator tests continue to pass unchanged server: - Add CONVENTIONS.md covering package structure, error handling, ID generation, database conventions, handler templates, auth context, testing, and logging rules - Move google/uuid from indirect to direct dependency in go.mod Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1 @@
|
||||
// Package idgen generates application-level identifiers.
|
||||
// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs).
|
||||
// For activation codes it produces 16-character Crockford Base32 strings
|
||||
// with a check digit, suitable for display and human entry.
|
||||
package idgen
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package idgen generates application-level identifiers.
|
||||
// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs).
|
||||
// For activation codes it produces 16-character Crockford Base32 strings
|
||||
// with a check digit, suitable for display and human entry.
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UUID v7 — time-ordered identifiers for database entities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// New returns a new UUID v7 (time-ordered, random node bits).
|
||||
// UUID v7 is the recommended identifier format for database entities; its
|
||||
// time-ordered structure improves B-tree index locality compared with UUID v4.
|
||||
// Panics only if the system's crypto/rand source is unavailable (a fatal
|
||||
// misconfiguration; panic is appropriate).
|
||||
func New() uuid.UUID {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
panic("idgen: crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// NewString returns a new UUID v7 as a canonical lower-case string
|
||||
// (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
|
||||
func NewString() string {
|
||||
return New().String()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Crockford Base32 — human-friendly activation codes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U
|
||||
// to prevent visual confusion with 1, 1, 0, and V respectively).
|
||||
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// crockfordCheck is the extended 37-symbol check-character alphabet used for
|
||||
// the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet;
|
||||
// symbols 32–36 are *, ~, $, =, U.
|
||||
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
|
||||
|
||||
// crockfordDecode maps every printable ASCII character to its Crockford
|
||||
// numeric value (0–31), or to -1 if the character is not valid.
|
||||
// Normalisation rules per the Crockford spec are applied at init time:
|
||||
//
|
||||
// I, i, l, L → 1
|
||||
// O, o → 0
|
||||
var crockfordDecode [128]int8
|
||||
|
||||
func init() {
|
||||
for i := range crockfordDecode {
|
||||
crockfordDecode[i] = -1
|
||||
}
|
||||
for i, ch := range crockfordAlphabet {
|
||||
crockfordDecode[ch] = int8(i)
|
||||
// Accept lower-case equivalents.
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
crockfordDecode[ch-'A'+'a'] = int8(i)
|
||||
}
|
||||
}
|
||||
// Normalisation: I/i/l/L → 1; O/o → 0.
|
||||
crockfordDecode['I'] = crockfordDecode['1']
|
||||
crockfordDecode['i'] = crockfordDecode['1']
|
||||
crockfordDecode['l'] = crockfordDecode['1']
|
||||
crockfordDecode['L'] = crockfordDecode['1']
|
||||
crockfordDecode['O'] = crockfordDecode['0']
|
||||
crockfordDecode['o'] = crockfordDecode['0']
|
||||
}
|
||||
|
||||
// computeCheckValue computes the Crockford mod-37 check value (Horner's method)
|
||||
// of the first 15 characters of s (which must already be in canonical form).
|
||||
// Returns -1 if any character is invalid.
|
||||
func computeCheckValue(s string) int {
|
||||
result := 0
|
||||
for i := 0; i < 15; i++ {
|
||||
ch := s[i]
|
||||
if ch >= 128 {
|
||||
return -1
|
||||
}
|
||||
v := int(crockfordDecode[ch])
|
||||
if v < 0 {
|
||||
return -1
|
||||
}
|
||||
result = (result*32 + v) % 37
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateCheckChar verifies that canonical[15] is the correct Crockford
|
||||
// mod-37 check symbol for canonical[0:15].
|
||||
func validateCheckChar(canonical string) error {
|
||||
if len(canonical) != 16 {
|
||||
return errors.New("idgen: invalid length for check validation")
|
||||
}
|
||||
expected := computeCheckValue(canonical)
|
||||
if expected < 0 {
|
||||
return errors.New("idgen: invalid data characters in code")
|
||||
}
|
||||
want := rune(crockfordCheck[expected])
|
||||
got := rune(canonical[15])
|
||||
if got != want {
|
||||
return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanonicalizeCode converts an activation-code string into its canonical form:
|
||||
// uppercase, with I/L→1 and O→0 substitutions applied, with the check
|
||||
// character validated. Hyphens and spaces are stripped before validation
|
||||
// (supports the common XXXX-XXXX-XXXX-XXXX display format).
|
||||
//
|
||||
// Returns an error if the string contains characters outside the normalised
|
||||
// Crockford alphabet, if the length (after stripping) is not exactly 16, or
|
||||
// if the check character is incorrect.
|
||||
func CanonicalizeCode(code string) (string, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
if len(code) != 16 {
|
||||
return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code))
|
||||
}
|
||||
|
||||
var buf [16]byte
|
||||
for i := 0; i < 16; i++ {
|
||||
ch := code[i]
|
||||
if ch >= 128 {
|
||||
return "", fmt.Errorf("idgen: non-ASCII character at position %d", i)
|
||||
}
|
||||
v := crockfordDecode[ch]
|
||||
if v < 0 {
|
||||
if i < 15 {
|
||||
return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i)
|
||||
}
|
||||
// Position 15 is the check character; validate separately below.
|
||||
buf[i] = []byte(strings.ToUpper(string(ch)))[0]
|
||||
continue
|
||||
}
|
||||
buf[i] = crockfordAlphabet[v]
|
||||
}
|
||||
|
||||
canonical := string(buf[:])
|
||||
if err := validateCheckChar(canonical); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext
|
||||
// activation code. This digest is the value stored in the database; the
|
||||
// plaintext code itself must never be persisted or logged.
|
||||
func HashCode(canonical string) string {
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GenerateCode generates a single random activation code in canonical
|
||||
// Crockford Base32 form: 15 random data characters followed by 1 mod-37
|
||||
// check character (16 characters total). Uses crypto/rand for
|
||||
// cryptographically-secure randomness.
|
||||
//
|
||||
// To avoid modular bias, the function uses rejection sampling: random bytes
|
||||
// in [0, 224) are accepted (224 = 7×32 ensures a uniform distribution over
|
||||
// the 32-symbol alphabet), bytes ≥ 224 are discarded.
|
||||
func GenerateCode() (string, error) {
|
||||
const dataLen = 15
|
||||
var buf [dataLen]byte
|
||||
i := 0
|
||||
for i < dataLen {
|
||||
// Over-read to reduce crypto/rand syscall count.
|
||||
var tmp [dataLen * 2]byte
|
||||
if _, err := rand.Read(tmp[:]); err != nil {
|
||||
return "", fmt.Errorf("idgen: crypto/rand: %w", err)
|
||||
}
|
||||
for _, b := range tmp {
|
||||
if b < 224 { // accept range: 224 = 7×32
|
||||
buf[i] = crockfordAlphabet[b%32]
|
||||
i++
|
||||
if i == dataLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data := string(buf[:])
|
||||
// Compute check value over the 15 data chars (the dummy 16th char is ignored).
|
||||
checkVal := computeCheckValue(data + "0")
|
||||
if checkVal < 0 {
|
||||
// Cannot happen: all buf[i] values come from crockfordAlphabet.
|
||||
return "", errors.New("idgen: internal check computation error")
|
||||
}
|
||||
return data + string(crockfordCheck[checkVal]), nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package idgen_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wangjia/pangolin/server/internal/idgen"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UUID v7 tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestNewReturnsVersion7 verifies that New() returns a UUID with version 7.
|
||||
func TestNewReturnsVersion7(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
id := idgen.New()
|
||||
if id.Version() != uuid.Version(7) {
|
||||
t.Fatalf("New(): version = %d, want 7", id.Version())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewStringFormat verifies that NewString() returns a properly formatted UUID string.
|
||||
func TestNewStringFormat(t *testing.T) {
|
||||
s := idgen.NewString()
|
||||
// Standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars)
|
||||
if len(s) != 36 {
|
||||
t.Fatalf("NewString() length = %d, want 36", len(s))
|
||||
}
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) != 5 {
|
||||
t.Fatalf("NewString() has %d hyphen-separated parts, want 5", len(parts))
|
||||
}
|
||||
expected := []int{8, 4, 4, 4, 12}
|
||||
for i, p := range parts {
|
||||
if len(p) != expected[i] {
|
||||
t.Errorf("part %d: length = %d, want %d", i, len(p), expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewUniqueness verifies that 10 000 generated UUIDs are all distinct.
|
||||
func TestNewUniqueness(t *testing.T) {
|
||||
const n = 10_000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
s := idgen.NewString()
|
||||
if _, dup := seen[s]; dup {
|
||||
t.Fatalf("duplicate UUID at iteration %d: %s", i, s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTimeOrdered verifies that sequentially generated UUIDs are
|
||||
// monotonically non-decreasing in their string representation (UUID v7 is
|
||||
// time-ordered so lexicographic sort ≈ generation order).
|
||||
func TestNewTimeOrdered(t *testing.T) {
|
||||
prev := idgen.NewString()
|
||||
for i := 0; i < 1000; i++ {
|
||||
next := idgen.NewString()
|
||||
if next < prev {
|
||||
t.Fatalf("UUID ordering violation: %s > %s", prev, next)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Crockford Base32 activation-code tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code
|
||||
// composed entirely of the Crockford alphabet (15 data chars + 1 check char).
|
||||
func TestGenerateCodeFormat(t *testing.T) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode error: %v", err)
|
||||
}
|
||||
if len(code) != 16 {
|
||||
t.Errorf("code %q: length = %d, want 16", code, len(code))
|
||||
}
|
||||
// A freshly generated code must canonicalize to itself.
|
||||
canonical, err := idgen.CanonicalizeCode(code)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(%q): %v", code, err)
|
||||
}
|
||||
if canonical != code {
|
||||
t.Errorf("canonical form mismatch: got %q, want %q", canonical, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateCodeUniqueness checks that 10 000 generated codes have no
|
||||
// hash collisions (birthday probability ≈ 10^−8 for 75-bit codes).
|
||||
func TestGenerateCodeUniqueness(t *testing.T) {
|
||||
const n = 10_000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
h := idgen.HashCode(code)
|
||||
if _, dup := seen[h]; dup {
|
||||
t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h)
|
||||
}
|
||||
seen[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeCodeNormalization verifies I/L→1 and O→0 substitutions.
|
||||
func TestCanonicalizeCodeNormalization(t *testing.T) {
|
||||
base, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
|
||||
// Lower-case input must produce the upper-case canonical form.
|
||||
lower := strings.ToLower(base)
|
||||
canonical, err := idgen.CanonicalizeCode(lower)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(lower) error: %v", err)
|
||||
}
|
||||
if canonical != base {
|
||||
t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base)
|
||||
}
|
||||
|
||||
// I, L → 1.
|
||||
idx := strings.IndexByte(base, '1')
|
||||
if idx >= 0 && idx < 15 {
|
||||
for _, sub := range []string{"I", "L", "i", "l"} {
|
||||
variant := base[:idx] + sub + base[idx+1:]
|
||||
c, err := idgen.CanonicalizeCode(variant)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
|
||||
continue
|
||||
}
|
||||
if c != base {
|
||||
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// O → 0.
|
||||
idx = strings.IndexByte(base, '0')
|
||||
if idx >= 0 && idx < 15 {
|
||||
variant := base[:idx] + "O" + base[idx+1:]
|
||||
c, err := idgen.CanonicalizeCode(variant)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
|
||||
} else if c != base {
|
||||
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCharDetectsSingleErrors verifies that mutating any single data
|
||||
// character in a valid code causes CanonicalizeCode to return an error.
|
||||
func TestCheckCharDetectsSingleErrors(t *testing.T) {
|
||||
const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
|
||||
for pos := 0; pos < 15; pos++ {
|
||||
original := rune(code[pos])
|
||||
for _, replacement := range alpha {
|
||||
if replacement == original {
|
||||
continue
|
||||
}
|
||||
mutated := code[:pos] + string(replacement) + code[pos+1:]
|
||||
if _, err := idgen.CanonicalizeCode(mutated); err == nil {
|
||||
t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q",
|
||||
pos, original, replacement, code, mutated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashCodeConsistency verifies that HashCode is deterministic and that
|
||||
// different codes produce different digests.
|
||||
func TestHashCodeConsistency(t *testing.T) {
|
||||
code1, _ := idgen.GenerateCode()
|
||||
code2, _ := idgen.GenerateCode()
|
||||
for code1 == code2 {
|
||||
code2, _ = idgen.GenerateCode()
|
||||
}
|
||||
|
||||
h1a := idgen.HashCode(code1)
|
||||
h1b := idgen.HashCode(code1)
|
||||
h2 := idgen.HashCode(code2)
|
||||
|
||||
if h1a != h1b {
|
||||
t.Error("HashCode is not deterministic")
|
||||
}
|
||||
if h1a == h2 {
|
||||
t.Error("different codes produced the same digest")
|
||||
}
|
||||
if len(h1a) != 64 {
|
||||
t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeCodeRejectsInvalidLength tests length validation.
|
||||
func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) {
|
||||
cases := []string{"", "ABCDE", "ABCDEFGH12345678X"}
|
||||
for _, c := range cases {
|
||||
if _, err := idgen.CanonicalizeCode(c); err == nil {
|
||||
t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeCodeRejectsInvalidChars tests that non-Crockford characters
|
||||
// at data positions (0–14) are rejected.
|
||||
func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) {
|
||||
base, _ := idgen.GenerateCode()
|
||||
invalid := "!" + base[1:]
|
||||
if _, err := idgen.CanonicalizeCode(invalid); err == nil {
|
||||
t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyphenStripping verifies that hyphens inserted for display readability
|
||||
// (e.g. XXXX-XXXX-XXXX-XXXX) are stripped before validation.
|
||||
func TestHyphenStripping(t *testing.T) {
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
|
||||
canonical, err := idgen.CanonicalizeCode(hyphenated)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(hyphenated) error: %v", err)
|
||||
}
|
||||
if canonical != code {
|
||||
t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user