From c2c67e35eba38f2d4b8b7f75e58170026dbd385a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 19 Jul 2026 21:33:48 -0400 Subject: [PATCH] refactor(model): extract canonical 128-bit base62 id codec --- model/id/id.go | 26 ++++++++++++++++---- model/id/id_suite_test.go | 17 +++++++++++++ model/id/id_test.go | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 model/id/id_suite_test.go create mode 100644 model/id/id_test.go diff --git a/model/id/id.go b/model/id/id.go index b54542898..56db3c80a 100644 --- a/model/id/id.go +++ b/model/id/id.go @@ -18,17 +18,33 @@ func NewRandom() string { return id } +// Encode128 renders a 16-byte value as the canonical 22-char zero-padded base62 id. +func Encode128(b []byte) string { + return fmt.Sprintf("%022s", new(big.Int).SetBytes(b).Text(62)) +} + +// Decode128 is the exact inverse of Encode128. +func Decode128(s string) ([]byte, error) { + if len(s) != 22 { + return nil, fmt.Errorf("invalid id length %d", len(s)) + } + v, ok := new(big.Int).SetString(s, 62) + if !ok || v.Sign() < 0 { + return nil, fmt.Errorf("invalid base62 id %q", s) + } + if v.BitLen() > 128 { + return nil, fmt.Errorf("id %q overflows 128 bits", s) + } + return v.FillBytes(make([]byte, 16)), nil +} + func NewHash(data ...string) string { hash := md5.New() for _, d := range data { hash.Write([]byte(d)) hash.Write([]byte(string('\u200b'))) } - h := hash.Sum(nil) - bi := big.NewInt(0) - bi.SetBytes(h) - s := bi.Text(62) - return fmt.Sprintf("%022s", s) + return Encode128(hash.Sum(nil)) } func NewTagID(name, value string) string { diff --git a/model/id/id_suite_test.go b/model/id/id_suite_test.go new file mode 100644 index 000000000..e8a41f4e5 --- /dev/null +++ b/model/id/id_suite_test.go @@ -0,0 +1,17 @@ +package id_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestID(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ID Suite") +} diff --git a/model/id/id_test.go b/model/id/id_test.go new file mode 100644 index 000000000..a3b20a865 --- /dev/null +++ b/model/id/id_test.go @@ -0,0 +1,50 @@ +package id_test + +import ( + "github.com/navidrome/navidrome/model/id" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Encode128/Decode128", func() { + It("encodes 16 bytes as 22-char zero-padded base62", func() { + Expect(id.Encode128(make([]byte, 16))).To(Equal("0000000000000000000000")) + allFF := []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + Expect(id.Encode128(allFF)).To(Equal("7N42dgm5tFLK9N8MT7fHC7")) + }) + + It("round-trips arbitrary 16-byte values", func() { + b := []byte{0xe3, 0xb7, 0xfc, 0x2a, 0xe9, 0x44, 0x7b, 0xbe, + 0xc3, 0x7a, 0x13, 0xbf, 0x91, 0x6e, 0x3c, 0xf6} + s := id.Encode128(b) + Expect(s).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) + Expect(id.Decode128(s)).To(Equal(b)) + }) + + It("rejects invalid input", func() { + _, err := id.Decode128("short") + Expect(err).To(HaveOccurred()) + _, err = id.Decode128("!!!!!!!!!!!!!!!!!!!!!!") // 22 chars, not base62 + Expect(err).To(HaveOccurred()) + _, err = id.Decode128("-000000000000000000001") // sign is not part of the alphabet + Expect(err).To(HaveOccurred()) + _, err = id.Decode128("zzzzzzzzzzzzzzzzzzzzzz") // > 2^128 + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("NewHash", func() { + It("keeps its historical output byte-for-byte (golden)", func() { + Expect(id.NewHash("test")).To(Equal("5cLJPkLA5DK2BADhoeotPk")) + Expect(id.NewHash("[unknown artist]")).To(Equal("7lsE5pS09fPS1VuFqwXbia")) + Expect(id.NewTagID("genre", "electronic")).To(Equal("7bLYq0Np81m1Wgy5N31nuG")) + }) + + It("always emits 22 decodable chars", func() { + h := id.NewHash("anything", "at", "all") + Expect(h).To(HaveLen(22)) + _, err := id.Decode128(h) + Expect(err).ToNot(HaveOccurred()) + }) +})