package main import ( "crypto/rand" "encoding/binary" "errors" "fmt" "strings" ) // cart_id.go // // Compact CartID implementation using 64 bits of cryptographic randomness, // base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64). // // Motivation: // * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId // * O(1) hashing (raw uint64) for consistent hashing ring integration // * Extremely low collision probability (birthday bound negligible at scale) // // Backward Compatibility Strategy (Phased): // Phase 1: Introduce CartID helpers while continuing to accept legacy CartId. // Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()). // Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header. // // NOTE: // The legacy type `CartId [16]byte` is still present elsewhere; helper // UpgradeLegacyCartId bridges that representation to the new form without // breaking deterministic mapping for existing carts. // // Security / Predictability: // Uses crypto/rand for generation. If ever required, you can layer an // HMAC-based derivation for additional secrecy. Current approach already // provides 64 bits of entropy (brute force infeasible for practical risk). // // Future Extensions: // * Time-sortable IDs: prepend a 48-bit timestamp field and encode 80 bits. // * Add metrics counters for: generated_new, parsed_existing, legacy_fallback. // * Add a pool of pre-generated IDs for ultra-low-latency hot paths (rarely needed). // // Public Surface Summary: // NewCartID() (CartID, error) // ParseCartID(string) (CartID, bool) // FallbackFromString(string) CartID // UpgradeLegacyCartId(CartId) CartID // CanonicalizeIncoming(string) (CartID, bool /*wasGenerated*/, error) // // Encoding Details: // encodeBase62 / decodeBase62 maintain a stable alphabet. DO NOT change // alphabet order once IDs are in circulation, or previously issued IDs // will change meaning. // // Zero Values: // The zero value CartID{} has raw=0, txt="0". Treat it as valid but // usually you will call NewCartID instead. // // --------------------------------------------------------------------------- const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" // Precomputed reverse lookup table for decode (255 = invalid). var base62Rev [256]byte func init() { for i := range base62Rev { base62Rev[i] = 0xFF } for i := 0; i < len(base62Alphabet); i++ { base62Rev[base62Alphabet[i]] = byte(i) } } // CartID is the compact representation of a cart identifier. // raw: 64-bit entropy (also used directly for consistent hashing). // txt: cached base62 textual form. type CartID struct { raw uint64 txt string } // String returns the canonical base62 encoded ID. func (c CartID) String() string { if c.txt == "" { // lazily encode if constructed manually c.txt = encodeBase62(c.raw) } return c.txt } // Raw returns the 64-bit numeric value (useful for hashing / ring lookup). func (c CartID) Raw() uint64 { return c.raw } // IsZero reports whether this CartID is the zero value. func (c CartID) IsZero() bool { return c.raw == 0 } // NewCartID generates a new cryptographically random 64-bit ID. func NewCartID() (CartID, error) { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return CartID{}, fmt.Errorf("NewCartID: %w", err) } u := binary.BigEndian.Uint64(b[:]) // Reject zero if you want to avoid ever producing "0" (optional). if u == 0 { // Extremely unlikely; recurse once. return NewCartID() } return CartID{raw: u, txt: encodeBase62(u)}, nil } // MustNewCartID panics on failure (suitable for tests / initialization). func MustNewCartID() CartID { id, err := NewCartID() if err != nil { panic(err) } return id } // ParseCartID attempts to parse a base62 canonical ID. // Returns (id, true) if fully valid; (zero, false) otherwise. func ParseCartID(s string) (CartID, bool) { if len(s) == 0 { return CartID{}, false } // Basic length sanity; allow a bit of headroom for future timestamp variant. if len(s) > 16 { return CartID{}, false } u, ok := decodeBase62(s) if !ok { return CartID{}, false } return CartID{raw: u, txt: s}, true } // FallbackFromString produces a deterministic CartID from arbitrary input // using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map // consistently into the new scheme (collision probability still low). func FallbackFromString(s string) CartID { const ( offset64 = 1469598103934665603 prime64 = 1099511628211 ) h := uint64(offset64) for i := 0; i < len(s); i++ { h ^= uint64(s[i]) h *= prime64 } return CartID{raw: h, txt: encodeBase62(h)} } // UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID // by hashing its trimmed string form. Keeps stable mapping across restarts. func UpgradeLegacyCartId(old CartId) CartID { return FallbackFromString(old.String()) } // CanonicalizeIncoming normalizes user-provided ID strings. // Behavior: // // Empty string -> generate new ID (wasGenerated = true) // Valid base62 -> parse and return (wasGenerated = false) // Anything else -> fallback deterministic hash (wasGenerated = false) // // Errors only occur if crypto/rand fails during generation. func CanonicalizeIncoming(s string) (CartID, bool, error) { if s == "" { id, err := NewCartID() return id, true, err } if cid, ok := ParseCartID(s); ok { return cid, false, nil } // Legacy heuristic: if length == 16 and contains non-base62 chars, treat as legacy padded ID. if len(s) == 16 && !isAllBase62(s) { return FallbackFromString(strings.TrimRight(s, "\x00")), false, nil } return FallbackFromString(s), false, nil } // isAllBase62 returns true if every byte is in the base62 alphabet. func isAllBase62(s string) bool { for i := 0; i < len(s); i++ { if base62Rev[s[i]] == 0xFF { return false } } return true } // encodeBase62 turns a uint64 into base62 text. // Complexity: O(log_62 n) ~ at most 11 iterations for 64 bits. func encodeBase62(u uint64) string { if u == 0 { return "0" } // 62^11 = 743008370688 > 2^39; 62^11 > 2^64? Actually 62^11 ~= 5.18e19 < 2^64 (1.84e19)? 2^64 ≈ 1.84e19. // 62^11 ≈ 5.18e19 > 2^64? Correction: 2^64 ≈ 1.844e19, so 62^11 > 2^64. Thus 11 chars suffice. var buf [11]byte i := len(buf) for u > 0 { i-- buf[i] = base62Alphabet[u%62] u /= 62 } return string(buf[i:]) } // decodeBase62 converts a base62 string to uint64. // Returns (value, false) if any invalid character appears. func decodeBase62(s string) (uint64, bool) { var v uint64 for i := 0; i < len(s); i++ { c := s[i] d := base62Rev[c] if d == 0xFF { return 0, false } v = v*62 + uint64(d) } return v, true } // ErrInvalidCartID can be returned by higher-level validation layers if you decide // to reject fallback-derived IDs (currently unused here). var ErrInvalidCartID = errors.New("invalid cart id") // --------------------------------------------------------------------------- // Legacy / Compatibility Conversion Helpers // --------------------------------------------------------------------------- // CartIDToLegacy converts a CartID (base62) into the legacy fixed-size CartId // ([16]byte) by copying the textual form (truncated or zero-padded). // NOTE: If the base62 string is longer than 16 (should not happen with current // 64-bit space), it will be truncated. func CartIDToLegacy(c CartID) CartId { var id CartId txt := c.String() copy(id[:], []byte(txt)) return id } // LegacyToCartID upgrades a legacy CartId (padded) to a CartID by hashing its // trimmed string form (deterministic). This preserves stable mapping without // depending on original randomness. func LegacyToCartID(old CartId) CartID { return UpgradeLegacyCartId(old) } // CartIDToKey returns the numeric key representation (uint64) for map indexing. func CartIDToKey(c CartID) uint64 { return c.Raw() } // LegacyToCartKey converts a legacy CartId to the numeric key via deterministic // fallback hashing. (Uses the same logic as LegacyToCartID then returns raw.) func LegacyToCartKey(old CartId) uint64 { return LegacyToCartID(old).Raw() } // ---------------------- Optional Helper Utilities ---------------------------- // CartIDOrNew tries to parse s; if empty OR invalid returns a fresh ID. func CartIDOrNew(s string) (CartID, bool /*wasParsed*/, error) { if cid, ok := ParseCartID(s); ok { return cid, true, nil } id, err := NewCartID() return id, false, err } // MustParseCartID panics if s is not a valid base62 ID (useful in tests). func MustParseCartID(s string) CartID { if cid, ok := ParseCartID(s); ok { return cid } panic(fmt.Sprintf("invalid CartID: %s", s)) } // DebugString returns a verbose description (for logging / diagnostics). func (c CartID) DebugString() string { return fmt.Sprintf("CartID(raw=%d txt=%s)", c.raw, c.String()) } // Equal compares two CartIDs by raw value. func (c CartID) Equal(other CartID) bool { return c.raw == other.raw } // CanonicalizeOrLegacy preserves legacy (non-base62) IDs without altering their // textual form, avoiding the previous behavior where fallback hashing replaced // the original string with a base62-encoded hash (which broke deterministic // key derivation across mixed call paths). // // Behavior: // - s == "" -> generate new CartID (generatedNew = true, wasBase62 = true) // - base62 ok -> return parsed CartID (generatedNew = false, wasBase62 = true) // - otherwise -> treat as legacy: raw = hash(s), txt = original s // // Returns: // // cid - CartID (txt preserved for legacy inputs) // generatedNew - true only when a brand new ID was created due to empty input // wasBase62 - true if the input was already canonical base62 (or generated) // err - only set if crypto/rand fails when generating a new ID func CanonicalizeOrLegacy(s string) (cid CartID, generatedNew bool, wasBase62 bool, err error) { if s == "" { id, e := NewCartID() if e != nil { return CartID{}, false, false, e } return id, true, true, nil } if parsed, ok := ParseCartID(s); ok { return parsed, false, true, nil } // Legacy path: keep original text so downstream legacy-to-key hashing // (which uses the visible string) yields consistent keys across code paths. hashCID := FallbackFromString(s) // Preserve original textual form hashCID.txt = s return hashCID, false, false, nil }