diff --git a/CLAUDE.md b/CLAUDE.md index ec6231b..67ce573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,10 +163,11 @@ package it exports, `LICENSE`, `CHANGELOG.md` with the fragment rule in (`.github/workflows/ci.yml`) and the release workflow (`.github/workflows/release.yml`). The package holds time-based and deterministic generation (`Uxid.generate` and `UxidOptions`), decoding -(`Uxid.decode`, `DecodeOptions` and `DecodeResult`) and the validity checks -(`Uxid.isValid` and `Uxid.isDeterministic`). The monotonic generator and the -vendored corpus arrive with later changes; until they do, the sentences in this file about the -1.0.0 package and its corpus describe the target, not the current tree. +(`Uxid.decode`, `DecodeOptions` and `DecodeResult`), the validity checks +(`Uxid.isValid` and `Uxid.isDeterministic`) and the monotonic generator +(`Monotonic`, with its `DrawSource`). The vendored corpus arrives with a later +change; until it does, the sentences in this file about the 1.0.0 package and +its corpus describe the target, not the current tree. - **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first release on Maven Central. diff --git a/changelog.d/uxjv-5tk.md b/changelog.d/uxjv-5tk.md new file mode 100644 index 0000000..397dfd4 --- /dev/null +++ b/changelog.d/uxjv-5tk.md @@ -0,0 +1,6 @@ +### Added + +- `Monotonic` is a generator the caller holds whose ids for one prefix and random width increase strictly, also within one millisecond and when the clock moves back: `generate()` and `generate(UxidOptions)` keep the last time and random value per prefix and random width, step the random field by a value drawn uniformly from 1 to 2^(4w) within a millisecond, and move the time one millisecond forward with a fresh seed when the field overflows. A monotonic `xs` uses compact time unless the options set the time form. +- One `Monotonic` may be shared between threads: a lock inside the instance orders the calls, so its ids still increase strictly. Nothing is kept in a static field, and two generators never affect each other. +- `DrawSource` replaces a generator's seeds and steps to replay known draws; by default they come from each call's byte source, the step masked from its bytes with no modulo bias. +- `Monotonic.generate(UxidOptions)` refuses a `from` input with `from_with_monotonic` and fixed random bytes with `rand_with_monotonic`. A call that throws leaves the generator's state as it was. diff --git a/src/main/java/systems/riddler/uxid/DrawSource.java b/src/main/java/systems/riddler/uxid/DrawSource.java new file mode 100644 index 0000000..c77e535 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/DrawSource.java @@ -0,0 +1,36 @@ +package systems.riddler.uxid; + +/** + * Where a {@link Monotonic} generator takes its random values from: a fresh random field, the seed, + * and the step that moves the field on within a millisecond. + * + *

A generator built with {@link Monotonic#Monotonic()} has no draw source of its own: it draws + * from each call's {@link UxidOptions.Builder#byteSource(ByteSource) byte source}, as {@link + * Monotonic} describes. A draw source of your own, passed to {@link + * Monotonic#Monotonic(DrawSource)}, replaces those draws, and the byte source is then never called. + * It exists to replay known values, as a test or a conformance case does, and is never a source of + * randomness for real ids: a monotonic id is exactly as hard to guess as its draws. + * + *

A generator calls its draw source with its lock held, so a draw source used by one generator + * is called by one thread at a time; one shared by several generators must be safe for that. + */ +public interface DrawSource { + + /** + * Returns a fresh random value for a random field of {@code width} bytes: a new array of exactly + * {@code width} bytes, read as one big-endian unsigned integer. + * + * @param width the random field's width in bytes, from 1 to 10 + * @return a new array of {@code width} bytes + */ + byte[] seed(int width); + + /** + * Returns a step drawn uniformly from 1 to 2^(4 x {@code width}) inclusive, half the random + * field's bit width. + * + * @param width the random field's width in bytes, from 1 to 10 + * @return the step, from 1 to 2^(4 x {@code width}) + */ + long step(int width); +} diff --git a/src/main/java/systems/riddler/uxid/Monotonic.java b/src/main/java/systems/riddler/uxid/Monotonic.java new file mode 100644 index 0000000..cbc13da --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Monotonic.java @@ -0,0 +1,279 @@ +package systems.riddler.uxid; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A monotonic generator: for one prefix and random width, the time and random value of each id it + * makes are greater than the last one's, also when several fall in one millisecond and when the + * clock moves back, so among ids of one letter case, delimiter and time form each sorts after the + * last as a string. It follows the "Monotonic generation" rule of the wire format. A monotonic id + * is an ordinary time-based id of its size and length: nothing in it says how it was made, and + * {@link Uxid#decode(String)} reads it as any other. + * + *

Each call takes a {@link UxidOptions} as {@link Uxid#generate(UxidOptions)} does, and the + * generator keeps its state per prefix and per random width: for each such pair it has seen, the + * last time {@code T} it gave and the last random value {@code R}, the random field read as one + * big-endian unsigned integer of {@code w} bytes. For a call at time {@code now} (the options' + * time, or {@link System#currentTimeMillis()} read at the call): + * + *

    + *
  1. with no state for the pair, or {@code now} later than {@code T}, the id carries {@code now} + * and a fresh random value, the seed; + *
  2. with {@code now} equal to or earlier than {@code T}, the id carries {@code T} and {@code R + * + step}, where {@code step} is drawn uniformly from 1 to 2^(4w) inclusive, half the field's + * bit width; + *
  3. when {@code R + step} is 2^(8w) or more, the id carries {@code T + 1} and a fresh seed + * instead. + *
+ * + *

The draws come from the {@link DrawSource} the generator was built with, or, for a generator + * built with {@link #Monotonic()}, from each call's {@link + * UxidOptions.Builder#byteSource(ByteSource) byte source}: a seed is {@code w} bytes, read + * big-endian, and a step is the low {@code 4w} bits of {@code ceil(w / 2)} bytes, read big-endian, + * plus one. The range of a step holds a power of two values, so the masked bits are exactly uniform + * over it, with no modulo bias and nothing redrawn. + * + *

The step is not a plus-one counter: consecutive ids in a millisecond are spread over a window + * of 2^(4w) values. That stops trivial enumeration, but it is a mitigation, not unpredictability, + * and a small random field stays small: a standard {@code s} id has 16 random bits. A compact time + * holds only the low 40 bits of the time, so compact ids keep their order only within one 2^40 ms + * window, as every compact id does. + * + *

At size {@link Size#XS} the generator uses compact time unless the options set the time form, + * because a standard {@code xs} has no random field to step and a compact {@code xs} has a one-byte + * field. The random width (the size's, or the options' {@link UxidOptions.Builder#randSize(int) + * random width}) must be at least one byte, so an {@code xs} with an explicit standard time and no + * random width is refused. + * + *

Thread safety. One generator may be shared between threads. A {@link + * java.util.concurrent.locks.ReentrantLock} inside the instance orders the calls: each call reads + * the time, draws and updates the state with the lock held, so the ids one generator makes for one + * prefix and random width increase strictly, as above, in the order the calls take the lock, + * whichever thread makes them. The draw source or byte source is called with the lock held. The + * state lives in the instance alone, with nothing kept in a static field, so two generators never + * affect each other, and ids from two generators carry no order between them within a millisecond. + * The state for each pair is kept for the generator's lifetime. + * + *

A refused call. A call that throws leaves the generator as it was: the state of every + * pair stays what it was before the call, and the next call starts from it. Draws a refused call + * already made are not given back. So after a step that overflows the field onto a compact time the + * wire format refuses, a later call at or before {@code T} draws a new step: it is refused again if + * that step also overflows the field, and with a step that does not, it answers an id at {@code T}. + * + *

{@code
+ * Monotonic ids = new Monotonic();
+ * UxidOptions options = UxidOptions.builder().prefix("usr").size(Size.S).build();
+ * String first = ids.generate(options);
+ * String second = ids.generate(options); // sorts after first, also in the same millisecond
+ * }
+ */ +public final class Monotonic { + /** The deterministic marker's value, which a compact time may not start with. */ + private static final long MARKER = 31; + + private final ReentrantLock lock = new ReentrantLock(); + private final DrawSource draws; + private final Map state = new HashMap<>(); + + /** + * Builds a generator that draws its seeds and steps from each call's {@link + * UxidOptions.Builder#byteSource(ByteSource) byte source}, by default {@link + * ByteSource#secureRandom()}. + */ + public Monotonic() { + this.draws = null; + } + + /** + * Builds a generator that takes its seeds and steps from {@code draws}; the options' byte source + * is never called. This replays known draws, as a test or a conformance case does; ids built from + * known draws are exactly as guessable as those draws, so production code uses {@link + * #Monotonic()}. + * + * @param draws the source of every seed and step + * @throws NullPointerException when {@code draws} is null + */ + public Monotonic(DrawSource draws) { + this.draws = Objects.requireNonNull(draws, "draws"); + } + + /** + * Generates the next id with every default: no prefix, size {@link Size#XL} with a standard time + * read from the clock now, lower case, and draws from {@link ByteSource#secureRandom()} unless + * the generator has a draw source. + * + * @return a 26-character id + * @throws IllegalArgumentException when the clock reads outside 0 to 2^48 - 1 milliseconds, or + * the field overflows in millisecond 2^48 - 1 + * @throws IllegalStateException when a draw answers outside its range + */ + public String generate() { + return generate(UxidOptions.builder().build()); + } + + /** + * Generates the next id for the options' prefix and random width, under the rule the class + * describes. The options' prefix, size, random width, time form, letter case, delimiter, time and + * byte source mean what they mean to {@link Uxid#generate(UxidOptions)}, with the same defaults, + * except that an {@link Size#XS} with no time form set uses compact time. + * + *

A call that throws leaves the generator as it was (see the class description). + * + * @param options the request + * @return the id + * @throws UxidException with {@link ErrorCode#FROM_WITH_MONOTONIC} when the options carry a + * {@link UxidOptions.Builder#from(String) from} input, since a deterministic id has no random + * field to step; {@link ErrorCode#RAND_WITH_MONOTONIC} for fixed {@link + * UxidOptions.Builder#rand(byte[]) random bytes}, since the generator chooses the field; + * {@link ErrorCode#RAND_SIZE_TOO_LARGE} for a random width above 10 bytes; {@link + * ErrorCode#COMPACT_TIME_OUT_OF_RANGE} for a compact time the id would carry whose first + * character would be the deterministic marker, also when that time is the millisecond after + * {@code T}, reached by an overflow. The first two are checked in that order before anything + * else. + * @throws IllegalArgumentException when the random width is 0 bytes (an {@code xs} with an + * explicit standard time and no random width, or a random width of 0); when the clock reads + * outside 0 to 2^48 - 1 milliseconds; or when the field overflows in millisecond 2^48 - 1, + * the last a time field holds + * @throws IllegalStateException when a byte source returns null or a count of bytes other than + * the one asked for, or a draw source answers a seed of the wrong length or a step outside 1 + * to 2^(4w) + * @throws NullPointerException when {@code options} is null + */ + public String generate(UxidOptions options) { + if (options.fromUtf8() != null) { + throw new UxidException( + ErrorCode.FROM_WITH_MONOTONIC, + "a monotonic generator makes no deterministic id: it has no random field to step"); + } + if (options.hasRand()) { + throw new UxidException( + ErrorCode.RAND_WITH_MONOTONIC, + "a monotonic generator takes no fixed random bytes: it chooses the random field"); + } + Boolean explicitCompact = options.compactTime(); + boolean compact = + explicitCompact == null ? options.size() == Size.XS : explicitCompact.booleanValue(); + Integer explicitWidth = options.randSize(); + int width = explicitWidth == null ? options.size().randBytes(compact) : explicitWidth; + if (width > Uxid.MAX_RAND_BYTES) { + throw new UxidException( + ErrorCode.RAND_SIZE_TOO_LARGE, + "the random field is at most " + Uxid.MAX_RAND_BYTES + " bytes, got " + width); + } + if (width == 0) { + throw new IllegalArgumentException( + "a monotonic id needs a random field to step, but this request's random width is 0" + + " bytes; leave the time form unset at size xs, or set a random width from 1 to 10"); + } + + lock.lock(); + try { + Long fixedTime = options.time(); + long now = + fixedTime == null ? UxidOptions.requireTime(System.currentTimeMillis()) : fixedTime; + Key key = new Key(options.prefix(), width); + Last last = state.get(key); + Last next; + if (last != null && now <= last.time()) { + BigInteger value = last.value().add(BigInteger.valueOf(step(options, width))); + if (value.bitLength() <= 8 * width) { + next = new Last(last.time(), value); + } else if (last.time() == UxidOptions.MAX_TIME) { + throw new IllegalArgumentException( + "the random field overflowed in millisecond 2^48 - 1, the last a time field holds"); + } else { + next = new Last(last.time() + 1, seed(options, width)); + } + } else { + next = new Last(now, seed(options, width)); + } + + String id = format(options, compact, width, next); + state.put(key, next); + return id; + } finally { + lock.unlock(); + } + } + + private static String format(UxidOptions options, boolean compact, int width, Last next) { + long compactTime = next.time() & ((1L << 40) - 1); + if (compact && compactTime >>> 35 == MARKER) { + throw new UxidException( + ErrorCode.COMPACT_TIME_OUT_OF_RANGE, + "a compact time from " + + next.time() + + " ms would start with the deterministic marker; use a standard time"); + } + StringBuilder id = new StringBuilder(options.prefix().length() + 30); + if (!options.prefix().isEmpty()) { + id.append(options.prefix()).append(options.delimiter()); + } + if (compact) { + Base32.appendInteger(id, compactTime, 8, options.letterCase()); + } else { + Base32.appendInteger(id, next.time(), 10, options.letterCase()); + } + Base32.appendBytes(id, toBytes(next.value(), width), options.letterCase()); + return id.toString(); + } + + /** A fresh random value for a field of {@code width} bytes. */ + private BigInteger seed(UxidOptions options, int width) { + byte[] bytes = + draws == null + ? Uxid.draw(options.byteSource(), width) + : checkSeed(draws.seed(width), width); + return new BigInteger(1, bytes); + } + + /** A step from 1 to 2^(4w), uniform over it. */ + private long step(UxidOptions options, int width) { + int bits = 4 * width; + long max = 1L << bits; + if (draws == null) { + byte[] bytes = Uxid.draw(options.byteSource(), (width + 1) / 2); + long drawn = 0; + for (byte b : bytes) { + drawn = (drawn << 8) | (b & 0xff); + } + return (drawn & (max - 1)) + 1; + } + long step = draws.step(width); + if (step < 1 || step > max) { + throw new IllegalStateException( + "the draw source answered the step " + step + ", outside 1 to 2^" + bits); + } + return step; + } + + private static byte[] checkSeed(byte[] seed, int width) { + if (seed == null || seed.length != width) { + throw new IllegalStateException( + "the draw source answered a seed of " + + (seed == null ? "null" : seed.length + " bytes") + + " for a field of " + + width); + } + return seed.clone(); + } + + /** The {@code width}-byte big-endian form of {@code value}, which is below 2^(8 x width). */ + private static byte[] toBytes(BigInteger value, int width) { + byte[] raw = value.toByteArray(); + byte[] bytes = new byte[width]; + int copy = Math.min(raw.length, width); + System.arraycopy(raw, raw.length - copy, bytes, width - copy, copy); + return bytes; + } + + /** The pair the state is kept for. */ + private record Key(String prefix, int width) {} + + /** The last id's time and random value for one pair. */ + private record Last(long time, BigInteger value) {} +} diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java index 424b8d2..4127330 100644 --- a/src/main/java/systems/riddler/uxid/Uxid.java +++ b/src/main/java/systems/riddler/uxid/Uxid.java @@ -32,6 +32,9 @@ * #isValid(String, String, String)} and {@link #isDeterministic(String, String)} answer the two * questions most callers ask of a string. * + *

Ids that must increase strictly within one millisecond come from a {@link Monotonic} + * generator, an object the caller holds with its own state. + * *

Every method is static and safe to call from several threads at once; the class holds no * configuration. */ @@ -301,7 +304,8 @@ private static MessageDigest sha256() { } } - private static byte[] draw(ByteSource source, int width) { + /** Draws {@code width} bytes from {@code source}, refusing an answer of any other length. */ + static byte[] draw(ByteSource source, int width) { byte[] bytes = source.nextBytes(width); if (bytes == null || bytes.length != width) { throw new IllegalStateException( diff --git a/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java new file mode 100644 index 0000000..78aaeac --- /dev/null +++ b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java @@ -0,0 +1,117 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Cases copied from the conformance corpus at uxid-ex v2.10.0 ({@code conformance/monotonic.json} + * and the monotonic refusals of {@code conformance/invalid.json}), each named by its case id. A + * list is written space-separated. The full corpus runner replaces this hand-picked copy. + */ +class MonotonicCorpusTest { + + @ParameterizedTest(name = "{0}") // (sabotage: overflow keeps T -> the two overflow cases failed) + @CsvSource( + nullValues = "null", + value = { + "monotonic/xl-same-millisecond, usr, xl, false, 123456789, null, 1 1099511627776 42," + + " 1700000000000 1700000000000 1700000000000 1700000000000," + + " usr_01hf7yat0000000000003nqk8n usr_01hf7yat0000000000003nqk8p" + + " usr_01hf7yat0000000001003nqk8p usr_01hf7yat0000000001003nqka0", + "monotonic/m-clock-moves-back, usr, m, false, 1000, null, 5 1048576 7," + + " 1700000000000 1699999999999 1699999999990 1700000000000," + + " usr_01hf7yat00000000z8 usr_01hf7yat00000000zd usr_01hf7yat00000100zd" + + " usr_01hf7yat00000100zm", + "monotonic/s-overflow-bumps-time, usr, s, false, 65000, 1234, 200 250 100 7 3," + + " 1700000000000 1700000000000 1700000000000 1700000000000 1700000000000" + + " 1700000000001," + + " usr_01hf7yat00zqm0 usr_01hf7yat00ztr0 usr_01hf7yat00zyn0 usr_01hf7yat010k90" + + " usr_01hf7yat010kc1 usr_01hf7yat010ke0", + "monotonic/xs-compact-overflow-clock-back, usr, xs, true, 250, 9, 5 16," + + " 1700000000000 1700000000000 1699999999999," + + " usr_hf7yat00z2 usr_hf7yat00z7 usr_hf7yat0111", + "monotonic/l-compact-single, usr, l, true, 0, null, '', 1700000000000," + + " usr_hf7yat000000000000000", + "monotonic/s-compact-no-prefix-largest-step, null, s, true, 0, null, 4096," + + " 1700000000000 1700000000000, hf7yat0000000 hf7yat0000800", + }) + void aMonotonicCaseGivesTheCorpusIds( + String id, + String prefix, + String size, + boolean compact, + long initialSeed, + Long overflowSeed, + String steps, + String times, + String expected) { + List seeds = new ArrayList<>(List.of(initialSeed)); + if (overflowSeed != null) { + seeds.add(overflowSeed); + } + TestSources.Replay draws = new TestSources.Replay(seeds, longs(steps)); + Monotonic generator = new Monotonic(draws); + + List ids = new ArrayList<>(); + for (long time : longs(times)) { + UxidOptions.Builder builder = + UxidOptions.builder() + .size(Size.parse(size)) + .compactTime(compact) + .time(time) + .byteSource(TestSources.refusing()); + if (prefix != null) { + builder.prefix(prefix); + } + ids.add(generator.generate(builder.build())); + } + + assertEquals(Arrays.asList(expected.split(" ", -1)), ids, id); + assertTrue(draws.drained(), id + ": every recorded draw is drawn"); + } + + @Test // (sabotage: from check skipped with a prefix -> assertThrows saw draws) + void fromWithAMonotonicGeneratorIsRefused() { + UxidOptions options = UxidOptions.builder().prefix("usr").from("alice@example.com").build(); + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals( + ErrorCode.FROM_WITH_MONOTONIC, refusal.code(), "invalid/generate-from-with-monotonic"); + } + + @Test // (sabotage: rand check skipped with a prefix -> assertThrows saw draws) + void fixedRandomBytesWithAMonotonicGeneratorAreRefused() { + UxidOptions options = + UxidOptions.builder() + .prefix("usr") + .rand(HexFormat.of().parseHex("0102")) + .size(Size.S) + .time(1_700_000_000_000L) + .build(); + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals( + ErrorCode.RAND_WITH_MONOTONIC, refusal.code(), "invalid/generate-rand-with-monotonic"); + } + + private static List longs(String list) { + List values = new ArrayList<>(); + for (String value : list.trim().split(" ", -1)) { + if (!value.isEmpty()) { + values.add(Long.parseLong(value)); + } + } + return values; + } +} diff --git a/src/test/java/systems/riddler/uxid/MonotonicTest.java b/src/test/java/systems/riddler/uxid/MonotonicTest.java new file mode 100644 index 0000000..fe4a207 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/MonotonicTest.java @@ -0,0 +1,402 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class MonotonicTest { + private static final long TIME = 1_700_000_000_000L; + + /** The last compact time whose first character is not the marker: 31 x 2^35 - 1. */ + private static final long LAST_COMPACT = 1_065_151_889_407L; + + @Test // (sabotage: step multiplied by 0 -> the second id assertion failed) + void aCallInTheSameMillisecondAddsTheStepToTheLastValue() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(7L, 1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 100), generator.generate(options)); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 107), generator.generate(options)); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 108), generator.generate(options)); + assertEquals(List.of("seed", "step", "step"), draws.calls); + } + + @Test // (sabotage: now <= T read as now == T -> a seed was drawn, the replay ran dry) + void aClockThatMovesBackKeepsTheLastTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(3L)); + Monotonic generator = new Monotonic(draws); + + generator.generate(at(TIME).prefix("usr").size(Size.M).build()); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.M), 5, 103), + generator.generate(at(TIME - 50).prefix("usr").size(Size.M).build())); + assertEquals(List.of("seed", "step"), draws.calls); + } + + @Test // (sabotage: now <= T + 1 -> a step was drawn, the replay ran dry) + void aLaterTimeTakesAFreshSeedAndTheNewTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L, 5L), List.of()); + Monotonic generator = new Monotonic(draws); + + generator.generate(at(TIME).prefix("usr").size(Size.S).build()); + assertEquals( + plain(at(TIME + 1).prefix("usr").size(Size.S), 2, 5), + generator.generate(at(TIME + 1).prefix("usr").size(Size.S).build())); + assertEquals(List.of("seed", "seed"), draws.calls); + } + + @Test // (sabotage: overflow keeps T -> the id assertion failed on the time) + void anOverflowMovesTheTimeOneMillisecondForwardWithAFreshSeed() { + TestSources.Replay draws = new TestSources.Replay(List.of(250L, 9L), List.of(6L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.XS).build(); + + generator.generate(options); + assertEquals( + plain(at(TIME + 1).prefix("usr").size(Size.XS).compactTime(true), 1, 9), + generator.generate(options)); + assertEquals(List.of("seed", "step", "seed"), draws.calls); + } + + @Test // (sabotage: key without the prefix -> cus stepped, the replay ran dry) + void theStateIsKeptPerPrefixAndPerRandomWidth() { + TestSources.Replay draws = + new TestSources.Replay(List.of(1L, 2L, 3L, 4L), List.of(10L, 20L, 30L, 40L)); + Monotonic generator = new Monotonic(draws); + UxidOptions usrS = at(TIME).prefix("usr").size(Size.S).build(); + UxidOptions cusS = at(TIME).prefix("cus").size(Size.S).build(); + UxidOptions usrM = at(TIME).prefix("usr").size(Size.M).build(); + UxidOptions usrWidth3 = at(TIME).prefix("usr").size(Size.S).randSize(3).build(); + + List ids = new ArrayList<>(); + for (int round = 0; round < 2; round++) { + ids.add(generator.generate(usrS)); + ids.add(generator.generate(cusS)); + ids.add(generator.generate(usrM)); + ids.add(generator.generate(usrWidth3)); + } + + List expected = + List.of( + plain(at(TIME).prefix("usr").size(Size.S), 2, 1), + plain(at(TIME).prefix("cus").size(Size.S), 2, 2), + plain(at(TIME).prefix("usr").size(Size.M), 5, 3), + plain(at(TIME).prefix("usr").size(Size.S).randSize(3), 3, 4), + plain(at(TIME).prefix("usr").size(Size.S), 2, 11), + plain(at(TIME).prefix("cus").size(Size.S), 2, 22), + plain(at(TIME).prefix("usr").size(Size.M), 5, 33), + plain(at(TIME).prefix("usr").size(Size.S).randSize(3), 3, 44)); + assertEquals(expected, ids); + } + + @Test // (sabotage: one static state map -> the second generator stepped, replay dry) + void twoGeneratorsKeepTheirOwnState() { + TestSources.Replay one = new TestSources.Replay(List.of(1L), List.of()); + TestSources.Replay two = new TestSources.Replay(List.of(2L), List.of(1L)); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + assertEquals( + plain(at(TIME).prefix("usr").size(Size.S), 2, 1), new Monotonic(one).generate(options)); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.S), 2, 2), new Monotonic(two).generate(options)); + } + + @Test // (sabotage: step mask dropped -> the compact s id assertion failed) + void theDefaultDrawsMaskTheByteSourceToTheStepRange() { + Monotonic s = new Monotonic(); + UxidOptions sOptions = + UxidOptions.builder() + .time(TIME) + .prefix("usr") + .size(Size.S) + .compactTime(true) + .byteSource(answering("000000", "ffff")) + .build(); + s.generate(sOptions); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.S).compactTime(true), 3, 4096), + s.generate(sOptions)); + + Monotonic xs = new Monotonic(); + UxidOptions xsOptions = + UxidOptions.builder() + .time(TIME) + .prefix("usr") + .size(Size.XS) + .byteSource(answering("00", "ff")) + .build(); + xs.generate(xsOptions); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.XS).compactTime(true), 1, 16), + xs.generate(xsOptions)); + } + + @Test // (sabotage: step drew w bytes -> the counts assertion failed) + void theDefaultDrawsAskTheByteSourceForTheSeedAndHalfTheWidth() { + TestSources.Counting source = new TestSources.Counting(); + Monotonic generator = new Monotonic(); + UxidOptions options = + UxidOptions.builder().time(TIME).prefix("usr").size(Size.M).byteSource(source).build(); + + generator.generate(options); + generator.generate(options); + assertEquals(List.of(5, 3), source.counts); + } + + @Test // (sabotage: draw source dropped -> the refusing byte source was called) + void aDrawSourceReplacesTheByteSource() { + TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of(1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.L).build(); + + generator.generate(options); + assertEquals(plain(at(TIME).prefix("usr").size(Size.L), 7, 2), generator.generate(options)); + } + + @Test // (sabotage: compact default moved to xl -> assertDoesNotThrow failed) + void anXsWithNoTimeFormUsesCompactTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of()); + UxidOptions options = at(TIME).prefix("usr").size(Size.XS).build(); + + Monotonic generator = new Monotonic(draws); + + assertEquals("usr_hf7yat0001", assertDoesNotThrow(() -> generator.generate(options))); + } + + @Test // (sabotage: width-0 check removed -> assertThrows saw the refusing draws) + void aRandomWidthOfZeroIsRefused() { + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + UxidOptions standardXs = at(TIME).size(Size.XS).compactTime(false).build(); + UxidOptions zeroWidth = at(TIME).randSize(0).build(); + + assertThrows(IllegalArgumentException.class, () -> generator.generate(standardXs)); + assertThrows(IllegalArgumentException.class, () -> generator.generate(zeroWidth)); + } + + @Test // (sabotage: width below 11 ignored -> assertDoesNotThrow failed) + void anExplicitWidthOnAStandardXsIsStepped() { + TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of(2L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).size(Size.XS).compactTime(false).randSize(2).build(); + + assertDoesNotThrow(() -> generator.generate(options)); + assertEquals( + plain(at(TIME).size(Size.XS).compactTime(false).randSize(2), 2, 3), + assertDoesNotThrow(() -> generator.generate(options))); + } + + @Test // (sabotage: cap raised to 11 -> assertThrows saw the refusing draws) + void aRandomWidthAboveTenIsRefused() { + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + UxidOptions options = at(TIME).randSize(11).build(); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals(ErrorCode.RAND_SIZE_TOO_LARGE, refusal.code()); + } + + @Test // (sabotage: from skipped when rand is set -> the code assertion failed) + void fromIsRefusedBeforeFixedRandomBytes() { + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + UxidOptions options = + at(TIME).from("alice@example.com").rand(new byte[] {1, 2}).size(Size.S).build(); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals(ErrorCode.FROM_WITH_MONOTONIC, refusal.code()); + } + + @Test // (sabotage: state cleared on the from refusal -> the R + step id failed) + void aRefusedCallLeavesTheGeneratorAsItWas() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L, 1L), List.of(5L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + generator.generate(options); + assertThrows( + UxidException.class, + () -> + generator.generate(at(TIME).prefix("usr").size(Size.S).from("a@example.com").build())); + assertThrows( + UxidException.class, + () -> generator.generate(at(TIME).prefix("usr").size(Size.S).rand(new byte[2]).build())); + assertThrows( + UxidException.class, + () -> generator.generate(at(TIME).prefix("usr").size(Size.S).randSize(11).build())); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 105), generator.generate(options)); + assertEquals(List.of("seed", "step"), draws.calls); + } + + @Test // (sabotage: state stored before the guard -> assertDoesNotThrow failed) + void anOverflowOntoTheMarkerIsRefusedAndLeavesTheLastTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(0xfeL, 7L), List.of(2L, 1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(LAST_COMPACT).prefix("usr").size(Size.XS).build(); + + assertEquals( + plain(at(LAST_COMPACT).prefix("usr").size(Size.XS).compactTime(true), 1, 0xfe), + generator.generate(options)); + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals(ErrorCode.COMPACT_TIME_OUT_OF_RANGE, refusal.code()); + assertEquals( + plain(at(LAST_COMPACT).prefix("usr").size(Size.XS).compactTime(true), 1, 0xff), + assertDoesNotThrow(() -> generator.generate(options))); + assertEquals(List.of("seed", "step", "seed", "step"), draws.calls); + } + + @Test // (sabotage: step range widened -> assertThrows saw nothing thrown) + void aDrawOutsideItsRangeIsRefusedAndLeavesTheGeneratorAsItWas() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(0L, 257L, 4L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + generator.generate(options); + assertThrows(IllegalStateException.class, () -> generator.generate(options)); + assertThrows(IllegalStateException.class, () -> generator.generate(options)); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 104), generator.generate(options)); + } + + @Test // (sabotage: a short seed accepted -> assertThrows saw nothing thrown) + void aSeedOfTheWrongLengthIsRefused() { + UxidOptions options = at(TIME).size(Size.S).build(); + Monotonic shortSeed = new Monotonic(seeding(new byte[1])); + Monotonic nullSeed = new Monotonic(seeding(null)); + Monotonic shortBytes = new Monotonic(); + UxidOptions shortSource = + UxidOptions.builder().time(TIME).size(Size.S).byteSource(count -> new byte[0]).build(); + + assertThrows(IllegalStateException.class, () -> shortSeed.generate(options)); + assertThrows(IllegalStateException.class, () -> nullSeed.generate(options)); + assertThrows(IllegalStateException.class, () -> shortBytes.generate(shortSource)); + } + + @Test // (sabotage: last-millisecond guard removed -> assertThrows saw a seed drawn) + void anOverflowInTheLastMillisecondIsRefused() { + long last = (1L << 48) - 1; + TestSources.Replay draws = new TestSources.Replay(List.of(0xffffL), List.of(1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(last).size(Size.S).build(); + + assertEquals("7zzzzzzzzzzzz1", generator.generate(options)); + assertThrows(IllegalArgumentException.class, () -> generator.generate(options)); + } + + @Test // (sabotage: clock read replaced by 0 -> the time range assertion failed) + void aCallWithNoTimeReadsTheClock() { + Monotonic generator = new Monotonic(); + long before = System.currentTimeMillis(); + String first = generator.generate(); + String second = generator.generate(); + long after = System.currentTimeMillis(); + + assertEquals(26, first.length(), first); + assertTrue(first.compareTo(second) < 0, first + " then " + second); + DecodeResult.Decoded decoded = (DecodeResult.Decoded) Uxid.decode(first); + long time = decoded.time().orElseThrow(); + assertTrue(time >= before && time <= after, first); + } + + @Test // (sabotage: a new lock per call -> a thread's ids did not increase) + void oneGeneratorSharedByThreadsGivesStrictlyIncreasingIds() throws Exception { + int threads = 8; + int perThread = 5_000; + Monotonic generator = new Monotonic(constantDraws()); + UxidOptions options = at(TIME).prefix("usr").size(Size.XL).build(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + List>> results = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + results.add( + pool.submit( + () -> { + start.await(); + List ids = new ArrayList<>(perThread); + for (int i = 0; i < perThread; i++) { + ids.add(generator.generate(options)); + } + return ids; + })); + } + start.countDown(); + Set all = new HashSet<>(); + for (Future> result : results) { + List ids = result.get(60, TimeUnit.SECONDS); + for (int i = 1; i < ids.size(); i++) { + assertTrue(ids.get(i - 1).compareTo(ids.get(i)) < 0, ids.get(i - 1) + " " + ids.get(i)); + } + all.addAll(ids); + } + assertEquals(threads * perThread, all.size()); + assertTrue( + all.contains(plain(at(TIME).prefix("usr").size(Size.XL), 10, threads * perThread - 1))); + } finally { + pool.shutdownNow(); + } + } + + /** A builder with a fixed time and a byte source that fails the test if it is called. */ + private static UxidOptions.Builder at(long time) { + return UxidOptions.builder().time(time).byteSource(TestSources.refusing()); + } + + /** + * The id {@link Uxid#generate(UxidOptions)} makes for {@code builder} with its {@code width}-byte + * random field fixed to {@code value}: what a monotonic call with that time and value answers. + */ + private static String plain(UxidOptions.Builder builder, int width, long value) { + byte[] field = new byte[width]; + long rest = value; + for (int i = width - 1; i >= 0; i--) { + field[i] = (byte) rest; + rest >>>= 8; + } + return Uxid.generate(builder.rand(field).build()); + } + + /** A byte source answering each hex string in turn, asked for exactly its length. */ + private static ByteSource answering(String... hex) { + List answers = new ArrayList<>(); + for (String h : hex) { + answers.add(HexFormat.of().parseHex(h)); + } + return count -> { + assertTrue(!answers.isEmpty(), "the byte source was asked for more than its answers"); + byte[] answer = answers.remove(0); + assertEquals(answer.length, count, "the count asked for"); + return answer; + }; + } + + /** A draw source whose every seed is {@code seed} and every step one. */ + private static DrawSource seeding(byte[] seed) { + return new DrawSource() { + @Override + public byte[] seed(int width) { + return seed; + } + + @Override + public long step(int width) { + return 1; + } + }; + } + + /** Seeds of zero and steps of one: safe to share, and any repeated id stays visible. */ + private static DrawSource constantDraws() { + return seeding(new byte[10]); + } +} diff --git a/src/test/java/systems/riddler/uxid/TestSources.java b/src/test/java/systems/riddler/uxid/TestSources.java index da8065b..a3acb98 100644 --- a/src/test/java/systems/riddler/uxid/TestSources.java +++ b/src/test/java/systems/riddler/uxid/TestSources.java @@ -1,6 +1,8 @@ package systems.riddler.uxid; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; /** Byte sources for tests. */ @@ -28,4 +30,66 @@ public byte[] nextBytes(int count) { return bytes; } } + + /** A draw source that fails the test if it is ever called. */ + static DrawSource refusingDraws() { + return new DrawSource() { + @Override + public byte[] seed(int width) { + throw new AssertionError("a seed was drawn for " + width + " bytes"); + } + + @Override + public long step(int width) { + throw new AssertionError("a step was drawn for " + width + " bytes"); + } + }; + } + + /** + * A draw source that answers recorded values in order, as the corpus gives them: each seed an + * unsigned integer written big-endian in the field's width, each step as it is. It records each + * draw as {@code seed} or {@code step} in {@link #calls}. + */ + static final class Replay implements DrawSource { + final List calls = new ArrayList<>(); + private final Deque seeds = new ArrayDeque<>(); + private final Deque steps = new ArrayDeque<>(); + + Replay(List seeds, List steps) { + this.seeds.addAll(seeds); + this.steps.addAll(steps); + } + + @Override + public byte[] seed(int width) { + calls.add("seed"); + Long value = seeds.poll(); + if (value == null) { + throw new AssertionError("no seed left to draw"); + } + byte[] bytes = new byte[width]; + long rest = value; + for (int i = width - 1; i >= 0; i--) { + bytes[i] = (byte) rest; + rest >>>= 8; + } + return bytes; + } + + @Override + public long step(int width) { + calls.add("step"); + Long value = steps.poll(); + if (value == null) { + throw new AssertionError("no step left to draw"); + } + return value; + } + + /** Whether every recorded seed and step was drawn. */ + boolean drained() { + return seeds.isEmpty() && steps.isEmpty(); + } + } }