From 0c5d7528da66461f2d93b666606e9739c6ca9c2d Mon Sep 17 00:00:00 2001 From: Perfloop Agent Date: Fri, 23 Oct 2026 03:00:49 +0000 Subject: [PATCH 1/3] fix: support full-width monotonic increments Signed-off-by: Perfloop Agent --- ulid.go | 18 ++++++--- ulid_test.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/ulid.go b/ulid.go index e07e1a6..adab5c9 100644 --- a/ulid.go +++ b/ulid.go @@ -584,7 +584,8 @@ func Monotonic(entropy io.Reader, inc uint64) *MonotonicEntropy { m.inc = math.MaxUint32 } - if rng, ok := entropy.(rng); ok { + // Int63n accepts only signed bounds, so larger increments use the byte path. + if rng, ok := entropy.(rng); ok && m.inc <= math.MaxInt64 { m.rng = rng } @@ -710,11 +711,18 @@ func (u *uint80) AppendTo(bs []byte) { } func (u *uint80) Add(n uint64) (overflow bool) { - lo, hi := u.Lo, u.Hi - if u.Lo += n; u.Lo < lo { - u.Hi++ + lo := u.Lo + u.Lo += n + if u.Lo >= lo { + return false } - return u.Hi < hi + if u.Hi == ^uint16(0) { + // Restore the low word so overflow leaves both words unchanged. + u.Lo = lo + return true + } + u.Hi++ + return false } func (u uint80) IsZero() bool { diff --git a/ulid_test.go b/ulid_test.go index afa194b..57ff667 100644 --- a/ulid_test.go +++ b/ulid_test.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "math" + "math/big" "math/rand" "strings" "testing" @@ -629,6 +630,110 @@ func TestMonotonic(t *testing.T) { } } +func TestMonotonicFullWidthIncrement(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + inc uint64 + limit uint64 + }{ + {math.MaxInt64, math.MaxInt64}, + {uint64(math.MaxInt64) + 1, uint64(math.MaxInt64) + 1}, + {math.MaxUint64, math.MaxUint64}, + } { + tt := tt + t.Run(fmt.Sprintf("inc=%d", tt.inc), func(t *testing.T) { + entropy := ulid.Monotonic(rand.New(rand.NewSource(1)), tt.inc) + prev, err := ulid.New(123, entropy) + if err != nil { + t.Fatal(err) + } + + prevEntropy := new(big.Int).SetBytes(prev[6:]) + if prevEntropy.Sign() == 0 { + t.Fatal("initial entropy is zero") + } + + var next ulid.ULID + var nextErr error + var panicValue interface{} + func() { + defer func() { panicValue = recover() }() + next, nextErr = ulid.New(123, entropy) + }() + if panicValue != nil { + t.Fatalf("same-timestamp New panicked: %v", panicValue) + } + if nextErr != nil { + t.Fatal(nextErr) + } + + delta := new(big.Int).Sub(new(big.Int).SetBytes(next[6:]), prevEntropy) + if delta.Sign() <= 0 || delta.Cmp(new(big.Int).SetUint64(tt.limit)) > 0 { + t.Fatalf("entropy increment %s is outside [1,%d]", delta, tt.limit) + } + }) + } + + t.Run("low-word-carry", func(t *testing.T) { + input := append([]byte{0, 1}, bytes.Repeat([]byte{0xff}, 8)...) + entropy := ulid.Monotonic(bytes.NewReader(input), 1) + prev, err := ulid.New(123, entropy) + if err != nil { + t.Fatal(err) + } + next, err := ulid.New(123, entropy) + if err != nil { + t.Fatal(err) + } + + delta := new(big.Int).Sub(new(big.Int).SetBytes(next[6:]), new(big.Int).SetBytes(prev[6:])) + if delta.Cmp(big.NewInt(1)) != 0 { + t.Fatalf("entropy increment %s, want 1", delta) + } + }) + + t.Run("full-width-overflow", func(t *testing.T) { + input := append(bytes.Repeat([]byte{0xff}, 10), bytes.Repeat([]byte{0xff}, 8)...) + // The all-ones sample is rejected; each following sample + // yields an increment of 2. + sample := []byte{1, 0, 0, 0, 0, 0, 0, 0} + input = append(input, sample...) + input = append(input, sample...) + entropy := ulid.Monotonic(bytes.NewReader(input), math.MaxUint64) + maxEntropy := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 80), big.NewInt(1)) + + prev, err := ulid.New(123, entropy) + if err != nil { + t.Fatal(err) + } + if got := new(big.Int).SetBytes(prev[6:]); got.Cmp(maxEntropy) != 0 { + t.Fatalf("initial entropy %s, want %s", got, maxEntropy) + } + + for attempt := 1; attempt <= 2; attempt++ { + if _, err := ulid.New(123, entropy); err != ulid.ErrMonotonicOverflow { + t.Fatalf("attempt %d: got error %v, want %v", attempt, err, ulid.ErrMonotonicOverflow) + } + } + }) + + t.Run("full-width-entropy-error", func(t *testing.T) { + readErr := io.ErrUnexpectedEOF + entropy := ulid.Monotonic(io.MultiReader( + bytes.NewReader(bytes.Repeat([]byte{0x01}, 10)), + bytes.NewReader([]byte{0x01, 0x02, 0x03}), + iotest.ErrReader(readErr), + ), uint64(math.MaxInt64)+1) + if _, err := ulid.New(123, entropy); err != nil { + t.Fatal(err) + } + if _, err := ulid.New(123, entropy); err != readErr { + t.Fatalf("got error %v, want %v", err, readErr) + } + }) +} + func TestMonotonicOverflow(t *testing.T) { t.Parallel() From 93dc68b1e79dc55cc1160b574d10a6166875bbc4 Mon Sep 17 00:00:00 2001 From: Perfloop Agent Date: Tue, 27 Oct 2026 05:37:11 +0000 Subject: [PATCH 2/3] test: use partial bytes for monotonic read error Signed-off-by: Perfloop Agent --- CHANGELOG.md | 4 ++++ ulid_test.go | 7 ++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da38c6..b91f199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* Handle full-width monotonic increments and keep entropy unchanged after overflow. + ## 1.3.1 / 2018-10-02 * Use underlying entropy source for random increments in Monotonic (#32) diff --git a/ulid_test.go b/ulid_test.go index 57ff667..f7606a2 100644 --- a/ulid_test.go +++ b/ulid_test.go @@ -720,11 +720,8 @@ func TestMonotonicFullWidthIncrement(t *testing.T) { t.Run("full-width-entropy-error", func(t *testing.T) { readErr := io.ErrUnexpectedEOF - entropy := ulid.Monotonic(io.MultiReader( - bytes.NewReader(bytes.Repeat([]byte{0x01}, 10)), - bytes.NewReader([]byte{0x01, 0x02, 0x03}), - iotest.ErrReader(readErr), - ), uint64(math.MaxInt64)+1) + input := append(bytes.Repeat([]byte{0x01}, 10), 0x01, 0x02, 0x03) + entropy := ulid.Monotonic(bytes.NewReader(input), uint64(math.MaxInt64)+1) if _, err := ulid.New(123, entropy); err != nil { t.Fatal(err) } From de3e364663b24eea1d6a6f675caeb0e6f2e22d97 Mon Sep 17 00:00:00 2001 From: Perfloop Agent Date: Tue, 27 Oct 2026 09:22:11 +0000 Subject: [PATCH 3/3] fix: branch directly on monotonic addition carry Signed-off-by: Perfloop Agent --- ulid.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/ulid.go b/ulid.go index adab5c9..ef410a1 100644 --- a/ulid.go +++ b/ulid.go @@ -711,17 +711,15 @@ func (u *uint80) AppendTo(bs []byte) { } func (u *uint80) Add(n uint64) (overflow bool) { - lo := u.Lo - u.Lo += n - if u.Lo >= lo { - return false - } - if u.Hi == ^uint16(0) { - // Restore the low word so overflow leaves both words unchanged. - u.Lo = lo - return true + lo, carry := bits.Add64(u.Lo, n, 0) + if carry != 0 { + if u.Hi == math.MaxUint16 { + // Leave both words unchanged when the full 80-bit value overflows. + return true + } + u.Hi++ } - u.Hi++ + u.Lo = lo return false }