From 62d0872fec26ff27e4c6730f0ac8c652004965db Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 21 Oct 2026 15:38:55 -0600 Subject: [PATCH 1/2] Adds time-based id generation with an injectable byte source Uxid.generate() and Uxid.generate(UxidOptions) build the time-based ids the uxid wire format defines: an optional prefix and delimiter, a standard or compact time field and a random field in Crockford Base32, at the five sizes, in either letter case. The options builder takes the prefix, size, random width, compact time, letter case, delimiter, a fixed time and fixed random bytes for test vectors. Random bytes come from a ByteSource, a functional interface taking a count; the default draws from one shared SecureRandom, so callers can inject another secure source without global configuration. A request the wire format refuses throws UxidException, an IllegalArgumentException carrying an ErrorCode from the format's closed set of generation errors; an option wrong on its own is refused by its setter instead. The tests carry a hand-picked subset of the corpus encode and generate-refusal cases at v2.10.0. The package-private anchor type goes: the package now exports real types, so javac no longer needs it. Refs: uxjv-94x --- CLAUDE.md | 12 +- changelog.d/uxjv-94x.md | 6 + .../java/systems/riddler/uxid/Base32.java | 46 +++ .../java/systems/riddler/uxid/ByteSource.java | 35 +++ .../java/systems/riddler/uxid/ErrorCode.java | 40 +++ .../java/systems/riddler/uxid/LetterCase.java | 12 + .../systems/riddler/uxid/PackageAnchor.java | 7 - .../riddler/uxid/SecureRandomSource.java | 23 ++ src/main/java/systems/riddler/uxid/Size.java | 84 ++++++ src/main/java/systems/riddler/uxid/Uxid.java | 113 ++++++++ .../systems/riddler/uxid/UxidException.java | 30 ++ .../systems/riddler/uxid/UxidOptions.java | 266 ++++++++++++++++++ .../riddler/uxid/GenerateCorpusTest.java | 128 +++++++++ .../riddler/uxid/SizeAndErrorCodeTest.java | 53 ++++ .../systems/riddler/uxid/TestSources.java | 31 ++ .../riddler/uxid/UxidGenerateTest.java | 230 +++++++++++++++ 16 files changed, 1104 insertions(+), 12 deletions(-) create mode 100644 changelog.d/uxjv-94x.md create mode 100644 src/main/java/systems/riddler/uxid/Base32.java create mode 100644 src/main/java/systems/riddler/uxid/ByteSource.java create mode 100644 src/main/java/systems/riddler/uxid/ErrorCode.java create mode 100644 src/main/java/systems/riddler/uxid/LetterCase.java delete mode 100644 src/main/java/systems/riddler/uxid/PackageAnchor.java create mode 100644 src/main/java/systems/riddler/uxid/SecureRandomSource.java create mode 100644 src/main/java/systems/riddler/uxid/Size.java create mode 100644 src/main/java/systems/riddler/uxid/Uxid.java create mode 100644 src/main/java/systems/riddler/uxid/UxidException.java create mode 100644 src/main/java/systems/riddler/uxid/UxidOptions.java create mode 100644 src/test/java/systems/riddler/uxid/GenerateCorpusTest.java create mode 100644 src/test/java/systems/riddler/uxid/SizeAndErrorCodeTest.java create mode 100644 src/test/java/systems/riddler/uxid/TestSources.java create mode 100644 src/test/java/systems/riddler/uxid/UxidGenerateTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 47cfa55..fa510f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,12 +158,14 @@ is `systems.riddler:uxid`, and its module and package are The tree on `main` today holds these agent instructions, the wurk manifest (`.claude/wurk.json`), the tracker files under `.beads/`, the build (`pom.xml`, `mise.toml` and `.mvn/jvm.config`), the module descriptor, the -package it exports (no public type yet), `LICENSE`, `CHANGELOG.md` with the -fragment rule in `changelog.d/README.md`, and the two workflows: CI +package it exports, `LICENSE`, `CHANGELOG.md` with the fragment rule in +`changelog.d/README.md`, and the two workflows: CI (`.github/workflows/ci.yml`) and the release workflow -(`.github/workflows/release.yml`). The library 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. +(`.github/workflows/release.yml`). The package holds time-based generation +(`Uxid.generate` and `UxidOptions`). Deterministic ids, decoding, the +validity checks, 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. - **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first release on Maven Central. diff --git a/changelog.d/uxjv-94x.md b/changelog.d/uxjv-94x.md new file mode 100644 index 0000000..7edb872 --- /dev/null +++ b/changelog.d/uxjv-94x.md @@ -0,0 +1,6 @@ +### Added + +- `Uxid.generate()` makes a time-based id with no prefix, size `xl`, a standard time read from the clock and ten random bytes from `SecureRandom`. +- `Uxid.generate(UxidOptions)` takes a prefix (`""` is none), a `Size` (`Size.parse` reads `xs`, `s`, `m`, `l`, `xl` and the long spellings `xsmall` to `xlarge`), a random width, compact time, a `LetterCase`, a delimiter (`_` by default), a fixed time and fixed random bytes, the last for reproducible test vectors only. +- `ByteSource`, a functional interface taking a count, supplies the random bytes; the default, `ByteSource.secureRandom()`, draws from `SecureRandom`. +- A generation request the wire format refuses throws `UxidException`, an `IllegalArgumentException` whose `code()` is an `ErrorCode`; each code's `wireName()` is its name in the wire format's closed set (`compact_time_out_of_range`, `rand_width_mismatch`, `rand_size_too_large`, `from_with_monotonic`, `rand_with_from`, `rand_with_monotonic`). diff --git a/src/main/java/systems/riddler/uxid/Base32.java b/src/main/java/systems/riddler/uxid/Base32.java new file mode 100644 index 0000000..1ff8433 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Base32.java @@ -0,0 +1,46 @@ +package systems.riddler.uxid; + +/** + * Crockford Base32 as the wire format writes it: most significant bit first, 5 bits per character, + * with no padding. A group shorter than 5 bits is written as its raw value. + */ +final class Base32 { + private static final String LOWER = "0123456789abcdefghjkmnpqrstvwxyz"; + private static final String UPPER = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + private Base32() {} + + private static String alphabet(LetterCase letterCase) { + return letterCase == LetterCase.UPPER ? UPPER : LOWER; + } + + /** + * Appends {@code chars} characters holding {@code value}, whose width is at most {@code chars x + * 5} bits; the first group is the short one when the width is not a multiple of 5. + */ + static void appendInteger(StringBuilder out, long value, int chars, LetterCase letterCase) { + String alphabet = alphabet(letterCase); + for (int i = chars - 1; i >= 0; i--) { + out.append(alphabet.charAt((int) ((value >>> (5 * i)) & 31))); + } + } + + /** Appends {@code bytes} as {@code ceil(8 x n / 5)} characters, the short group last. */ + static void appendBytes(StringBuilder out, byte[] bytes, LetterCase letterCase) { + String alphabet = alphabet(letterCase); + int buffer = 0; + int bits = 0; + for (byte b : bytes) { + buffer = (buffer << 8) | (b & 0xff); + bits += 8; + while (bits >= 5) { + bits -= 5; + out.append(alphabet.charAt((buffer >>> bits) & 31)); + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) { + out.append(alphabet.charAt(buffer)); + } + } +} diff --git a/src/main/java/systems/riddler/uxid/ByteSource.java b/src/main/java/systems/riddler/uxid/ByteSource.java new file mode 100644 index 0000000..9f025db --- /dev/null +++ b/src/main/java/systems/riddler/uxid/ByteSource.java @@ -0,0 +1,35 @@ +package systems.riddler.uxid; + +import java.security.SecureRandom; + +/** + * Where an id's random field comes from: given a count, it returns that many random bytes. + * + *

The default, {@link #secureRandom()}, draws from {@link SecureRandom}. A replacement must be a + * cryptographically secure source as well: the random field is what keeps two ids made in the same + * millisecond apart, and what keeps an id from being guessed. Inject one to use a different secure + * source; to reproduce a fixed id for a test vector, set {@link UxidOptions.Builder#rand(byte[]) + * fixed random bytes} instead. + */ +@FunctionalInterface +public interface ByteSource { + + /** + * Returns {@code count} random bytes, in a new array of exactly that length. The count is the + * random field's width, from 1 to 10; a request with no random field does not call the source. + * + * @param count the number of bytes wanted + * @return a new array of {@code count} random bytes + */ + byte[] nextBytes(int count); + + /** + * Returns the default source, which draws from one {@link SecureRandom} shared by every caller. + * {@code SecureRandom} is safe to use from several threads at once. + * + * @return the default source + */ + static ByteSource secureRandom() { + return SecureRandomSource.INSTANCE; + } +} diff --git a/src/main/java/systems/riddler/uxid/ErrorCode.java b/src/main/java/systems/riddler/uxid/ErrorCode.java new file mode 100644 index 0000000..f6d3689 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/ErrorCode.java @@ -0,0 +1,40 @@ +package systems.riddler.uxid; + +/** + * The generation errors of the wire format, a closed set. Each constant's {@link #wireName()} is + * the name the wire format and the conformance corpus give the refusal. + * + *

A {@link UxidException} carries one of these as its {@link UxidException#code() code}. + */ +public enum ErrorCode { + /** + * A compact time whose first character would be the deterministic marker: the low 40 bits of the + * time are at or above 31 x 2^35. + */ + COMPACT_TIME_OUT_OF_RANGE("compact_time_out_of_range"), + /** Fixed random bytes whose count is not the request's random width. */ + RAND_WIDTH_MISMATCH("rand_width_mismatch"), + /** An explicit random width above 10 bytes. */ + RAND_SIZE_TOO_LARGE("rand_size_too_large"), + /** A deterministic body asked for with monotonic generation. */ + FROM_WITH_MONOTONIC("from_with_monotonic"), + /** Fixed random bytes asked for with a deterministic body, which has no random field. */ + RAND_WITH_FROM("rand_with_from"), + /** Fixed random bytes asked for with monotonic generation, which chooses the field itself. */ + RAND_WITH_MONOTONIC("rand_with_monotonic"); + + private final String wireName; + + ErrorCode(String wireName) { + this.wireName = wireName; + } + + /** + * Returns the error's name in the wire format's closed set, such as {@code rand_width_mismatch}. + * + * @return the wire name + */ + public String wireName() { + return wireName; + } +} diff --git a/src/main/java/systems/riddler/uxid/LetterCase.java b/src/main/java/systems/riddler/uxid/LetterCase.java new file mode 100644 index 0000000..37848bf --- /dev/null +++ b/src/main/java/systems/riddler/uxid/LetterCase.java @@ -0,0 +1,12 @@ +package systems.riddler.uxid; + +/** + * The letter case of an id's body. Case is display only: an upper-case body and its lower-case + * spelling are the same id. It never applies to the prefix, which is emitted as given. + */ +public enum LetterCase { + /** Lower-case body letters, the default. */ + LOWER, + /** Upper-case body letters. */ + UPPER +} diff --git a/src/main/java/systems/riddler/uxid/PackageAnchor.java b/src/main/java/systems/riddler/uxid/PackageAnchor.java deleted file mode 100644 index 9fb123d..0000000 --- a/src/main/java/systems/riddler/uxid/PackageAnchor.java +++ /dev/null @@ -1,7 +0,0 @@ -package systems.riddler.uxid; - -/** - * Keeps this package non-empty while it holds no other type: javac refuses to export an empty - * package from {@code module-info.java}. It is package-private, so it is not part of the API. - */ -interface PackageAnchor {} diff --git a/src/main/java/systems/riddler/uxid/SecureRandomSource.java b/src/main/java/systems/riddler/uxid/SecureRandomSource.java new file mode 100644 index 0000000..fbf34bb --- /dev/null +++ b/src/main/java/systems/riddler/uxid/SecureRandomSource.java @@ -0,0 +1,23 @@ +package systems.riddler.uxid; + +import java.security.SecureRandom; + +/** + * The default {@link ByteSource}: one {@link SecureRandom}, created on first use and shared, as + * {@link java.util.UUID#randomUUID()} shares its own. It holds no configuration and nothing a + * caller can observe or change. + */ +final class SecureRandomSource implements ByteSource { + static final SecureRandomSource INSTANCE = new SecureRandomSource(); + + private final SecureRandom random = new SecureRandom(); + + private SecureRandomSource() {} + + @Override + public byte[] nextBytes(int count) { + byte[] bytes = new byte[count]; + random.nextBytes(bytes); + return bytes; + } +} diff --git a/src/main/java/systems/riddler/uxid/Size.java b/src/main/java/systems/riddler/uxid/Size.java new file mode 100644 index 0000000..ed21173 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Size.java @@ -0,0 +1,84 @@ +package systems.riddler.uxid; + +import java.util.Objects; + +/** + * The size of an id: with the time form, it fixes the random field's width and the body length. + * + *

The table, from the wire format, gives the random field's width in bytes and the body length + * in characters for a standard time (48 bits, 10 characters) and for a compact time (the low 40 + * bits, 8 characters). Compact time hands the 8 bits it frees to the random field, except at {@link + * #XL}, whose random field is already at the 10-byte cap. + * + * + * + * + * + * + * + * + * + * + *
Random bytes and body length per size
SizeStandard random bytesStandard lengthCompact random bytesCompact length
{@code xs}010110
{@code s}214313
{@code m}518618
{@code l}722821
{@code xl}10261024
+ * + *

The default size is {@link #XL}. + */ +public enum Size { + /** Extra small: no random bytes with a standard time, one with a compact time. */ + XS("xs", "xsmall", 0, 1), + /** Small: two random bytes with a standard time, three with a compact time. */ + S("s", "small", 2, 3), + /** Medium: five random bytes with a standard time, six with a compact time. */ + M("m", "medium", 5, 6), + /** Large: seven random bytes with a standard time, eight with a compact time. */ + L("l", "large", 7, 8), + /** Extra large, the default: ten random bytes with either time form. */ + XL("xl", "xlarge", 10, 10); + + private final String wireName; + private final String longName; + private final int standardRandBytes; + private final int compactRandBytes; + + Size(String wireName, String longName, int standardRandBytes, int compactRandBytes) { + this.wireName = wireName; + this.longName = longName; + this.standardRandBytes = standardRandBytes; + this.compactRandBytes = compactRandBytes; + } + + /** + * Returns the size's short spelling, the one the conformance corpus uses: {@code xs}, {@code s}, + * {@code m}, {@code l} or {@code xl}. + * + * @return the short spelling + */ + public String wireName() { + return wireName; + } + + /** + * Returns the size a spelling names: a short spelling ({@code xs}, {@code s}, {@code m}, {@code + * l}, {@code xl}) or a long one ({@code xsmall}, {@code small}, {@code medium}, {@code large}, + * {@code xlarge}). The spellings are lower case and matched exactly. + * + * @param name the spelling + * @return the size it names + * @throws IllegalArgumentException when {@code name} is not one of the ten spellings + * @throws NullPointerException when {@code name} is null + */ + public static Size parse(String name) { + Objects.requireNonNull(name, "name"); + for (Size size : values()) { + if (size.wireName.equals(name) || size.longName.equals(name)) { + return size; + } + } + throw new IllegalArgumentException("not a size: \"" + name + "\""); + } + + /** The random field's width in bytes for this size and time form. */ + int randBytes(boolean compact) { + return compact ? compactRandBytes : standardRandBytes; + } +} diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java new file mode 100644 index 0000000..f1a3ca3 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Uxid.java @@ -0,0 +1,113 @@ +package systems.riddler.uxid; + +/** + * Generates UXIDs: prefixed, K-sortable identifiers a person can read, in the wire format of the + * Elixir {@code uxid} package. + * + *

A generated id is an optional prefix and the delimiter, then a body: a time field (a standard + * 48-bit time in 10 characters, or a compact 40-bit time in 8) followed by a random field, in + * Crockford Base32. Among ids of one prefix, size and case with a standard time, an id made in a + * later millisecond sorts after one made in an earlier millisecond, compared character by + * character. + * + *

{@code
+ * String id = Uxid.generate(); // a 26-character body, no prefix
+ * String user = Uxid.generate(UxidOptions.builder().prefix("usr").size(Size.M).build());
+ * }
+ * + *

Every method is static and safe to call from several threads at once; the class holds no + * configuration. + */ +public final class Uxid { + /** The widest random field at any size, in bytes. */ + static final int MAX_RAND_BYTES = 10; + + /** The deterministic marker's value, which a compact time may not start with. */ + private static final long MARKER = 31; + + private Uxid() {} + + /** + * Generates an id with every default: no prefix, size {@link Size#XL} with a standard time read + * from the clock now, lower case, and ten random bytes from {@link ByteSource#secureRandom()}. + * + * @return a 26-character id + */ + public static String generate() { + return generate(UxidOptions.builder().build()); + } + + /** + * Generates an id from {@code options}. + * + *

The time is the options' time, or {@link System#currentTimeMillis()} read now. The random + * field's width is the options' random width, or the width the size and time form give; its bytes + * are the options' fixed bytes, or that many bytes from the options' byte source. + * + * @param options the request + * @return the id + * @throws UxidException with {@link ErrorCode#COMPACT_TIME_OUT_OF_RANGE} for a compact time whose + * first character would be the deterministic marker; {@link ErrorCode#RAND_SIZE_TOO_LARGE} + * for a random width above 10 bytes; {@link ErrorCode#RAND_WIDTH_MISMATCH} for fixed random + * bytes whose count is not the random width + * @throws IllegalArgumentException when the clock reads outside 0 to 2^48 - 1 milliseconds + * @throws IllegalStateException when the byte source returns null or a count of bytes other than + * the one asked for + * @throws NullPointerException when {@code options} is null + */ + public static String generate(UxidOptions options) { + Long fixedTime = options.time(); + long time = fixedTime == null ? UxidOptions.requireTime(System.currentTimeMillis()) : fixedTime; + boolean compact = Boolean.TRUE.equals(options.compactTime()); + + Integer explicitWidth = options.randSize(); + int width = explicitWidth == null ? options.size().randBytes(compact) : explicitWidth; + if (width > MAX_RAND_BYTES) { + throw new UxidException( + ErrorCode.RAND_SIZE_TOO_LARGE, + "the random field is at most " + MAX_RAND_BYTES + " bytes, got " + width); + } + byte[] rand = options.rand(); + if (rand != null && rand.length != width) { + throw new UxidException( + ErrorCode.RAND_WIDTH_MISMATCH, + "fixed random bytes must be " + width + " bytes for this request, got " + rand.length); + } + + long compactTime = time & ((1L << 40) - 1); + if (compact && compactTime >>> 35 == MARKER) { + throw new UxidException( + ErrorCode.COMPACT_TIME_OUT_OF_RANGE, + "a compact time from " + + time + + " ms would start with the deterministic marker; use a standard time"); + } + if (rand == null) { + rand = width == 0 ? new byte[0] : draw(options.byteSource(), width); + } + + 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, time, 10, options.letterCase()); + } + Base32.appendBytes(id, rand, options.letterCase()); + return id.toString(); + } + + private static byte[] draw(ByteSource source, int width) { + byte[] bytes = source.nextBytes(width); + if (bytes == null || bytes.length != width) { + throw new IllegalStateException( + "the byte source returned " + + (bytes == null ? "null" : bytes.length + " bytes") + + " when asked for " + + width); + } + return bytes; + } +} diff --git a/src/main/java/systems/riddler/uxid/UxidException.java b/src/main/java/systems/riddler/uxid/UxidException.java new file mode 100644 index 0000000..7300692 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/UxidException.java @@ -0,0 +1,30 @@ +package systems.riddler.uxid; + +/** + * Thrown when a generation request contradicts itself or the wire format. Its {@link #code()} is + * the refusal's name in the wire format's closed set of generation errors. + * + *

An option value that is wrong on its own (a null, an empty delimiter, a negative random width, + * a time outside 48 bits) is refused when it is set, with a plain {@link IllegalArgumentException} + * or {@link NullPointerException}, not with this exception. + */ +public final class UxidException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + /** The refusal's name. */ + private final ErrorCode code; + + UxidException(ErrorCode code, String detail) { + super(code.wireName() + ": " + detail); + this.code = code; + } + + /** + * Returns the refusal's name in the wire format's closed set of generation errors. + * + * @return the error code + */ + public ErrorCode code() { + return code; + } +} diff --git a/src/main/java/systems/riddler/uxid/UxidOptions.java b/src/main/java/systems/riddler/uxid/UxidOptions.java new file mode 100644 index 0000000..7605fae --- /dev/null +++ b/src/main/java/systems/riddler/uxid/UxidOptions.java @@ -0,0 +1,266 @@ +package systems.riddler.uxid; + +import java.util.Objects; + +/** + * The options of one {@link Uxid#generate(UxidOptions) generate} request, built with {@link + * #builder()}. An instance is immutable and safe to share between threads and calls. + * + *

Every option has a default, so {@code UxidOptions.builder().build()} asks for what {@link + * Uxid#generate()} makes: no prefix, size {@link Size#XL}, a standard time read from the clock at + * the call, lower case, the delimiter {@code _} and random bytes from {@link + * ByteSource#secureRandom()}. + * + *

{@code
+ * UxidOptions options = UxidOptions.builder().prefix("usr").size(Size.S).build();
+ * String id = Uxid.generate(options); // usr_ followed by a 14-character body
+ * }
+ */ +public final class UxidOptions { + /** The latest time a standard time field holds, in milliseconds: 2^48 - 1. */ + static final long MAX_TIME = (1L << 48) - 1; + + private final String prefix; + private final Size size; + private final Integer randSize; + private final Boolean compactTime; + private final LetterCase letterCase; + private final String delimiter; + private final Long time; + private final byte[] rand; + private final ByteSource byteSource; + + private UxidOptions(Builder builder) { + this.prefix = builder.prefix; + this.size = builder.size; + this.randSize = builder.randSize; + this.compactTime = builder.compactTime; + this.letterCase = builder.letterCase; + this.delimiter = builder.delimiter; + this.time = builder.time; + this.rand = builder.rand == null ? null : builder.rand.clone(); + this.byteSource = builder.byteSource; + } + + /** + * Returns a builder holding every default. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + String prefix() { + return prefix; + } + + Size size() { + return size; + } + + /** The explicit random width in bytes, or null for the size's own width. */ + Integer randSize() { + return randSize; + } + + /** The explicit time form (true for compact), or null when it was not set. */ + Boolean compactTime() { + return compactTime; + } + + LetterCase letterCase() { + return letterCase; + } + + String delimiter() { + return delimiter; + } + + /** The explicit time in milliseconds, or null to read the clock at the call. */ + Long time() { + return time; + } + + /** A copy of the fixed random bytes, or null to draw them from the byte source. */ + byte[] rand() { + return rand == null ? null : rand.clone(); + } + + ByteSource byteSource() { + return byteSource; + } + + /** + * Builds a {@link UxidOptions}. Each setter refuses a value that is wrong on its own; a + * combination of values that contradicts the wire format is refused by {@link + * Uxid#generate(UxidOptions)} with a {@link UxidException}. A builder is not safe to share + * between threads. + */ + public static final class Builder { + private String prefix = ""; + private Size size = Size.XL; + private Integer randSize; + private Boolean compactTime; + private LetterCase letterCase = LetterCase.LOWER; + private String delimiter = "_"; + private Long time; + private byte[] rand; + private ByteSource byteSource = ByteSource.secureRandom(); + + private Builder() {} + + /** + * Sets the prefix, emitted before the delimiter exactly as given. The empty string, the + * default, is no prefix: the id is the bare body, with no delimiter. A prefix may contain the + * delimiter. + * + * @param prefix the prefix, or {@code ""} for none + * @return this builder + * @throws NullPointerException when {@code prefix} is null + */ + public Builder prefix(String prefix) { + this.prefix = Objects.requireNonNull(prefix, "prefix"); + return this; + } + + /** + * Sets the size, which fixes the random field's width; the default is {@link Size#XL}. {@link + * Size#parse(String)} reads the short and long spellings. + * + * @param size the size + * @return this builder + * @throws NullPointerException when {@code size} is null + */ + public Builder size(Size size) { + this.size = Objects.requireNonNull(size, "size"); + return this; + } + + /** + * Sets the random field's width in bytes, overriding the width the size and time form give. A + * body built with a width outside the size table has the table's layout but not one of its + * lengths, so a decoder refuses it. A width above 10 is refused at generation with {@link + * ErrorCode#RAND_SIZE_TOO_LARGE}. + * + * @param randSize the width in bytes, 0 or more + * @return this builder + * @throws IllegalArgumentException when {@code randSize} is negative + */ + public Builder randSize(int randSize) { + if (randSize < 0) { + throw new IllegalArgumentException("randSize must be 0 or more, got " + randSize); + } + this.randSize = randSize; + return this; + } + + /** + * Chooses the time form: {@code true} for a compact time (the low 40 bits of the time, 8 + * characters), {@code false} for a standard time (48 bits, 10 characters), the default. Compact + * time gives most sizes one more random byte. A compact time whose first character would be the + * deterministic marker (from mid-2038 until the 40-bit window rolls over in 2039) is refused at + * generation with {@link ErrorCode#COMPACT_TIME_OUT_OF_RANGE}. + * + * @param compactTime {@code true} for a compact time + * @return this builder + */ + public Builder compactTime(boolean compactTime) { + this.compactTime = compactTime; + return this; + } + + /** + * Sets the letter case of the body; the default is {@link LetterCase#LOWER}. The prefix keeps + * its own case. + * + * @param letterCase the body's letter case + * @return this builder + * @throws NullPointerException when {@code letterCase} is null + */ + public Builder letterCase(LetterCase letterCase) { + this.letterCase = Objects.requireNonNull(letterCase, "letterCase"); + return this; + } + + /** + * Sets the delimiter between the prefix and the body; the default is {@code _}. A decoder must + * use the same one: it is not recorded in the id. A delimiter made of alphabet characters makes + * the prefix and body ambiguous on decode and is not supported. + * + * @param delimiter the delimiter, not empty + * @return this builder + * @throws IllegalArgumentException when {@code delimiter} is empty + * @throws NullPointerException when {@code delimiter} is null + */ + public Builder delimiter(String delimiter) { + Objects.requireNonNull(delimiter, "delimiter"); + if (delimiter.isEmpty()) { + throw new IllegalArgumentException("the delimiter must not be empty"); + } + this.delimiter = delimiter; + return this; + } + + /** + * Fixes the id's time, in milliseconds since the Unix epoch. When it is not set, the time is + * read from {@link System#currentTimeMillis()} at each call. + * + * @param millis the time, from 0 to 2^48 - 1 + * @return this builder + * @throws IllegalArgumentException when {@code millis} is outside 0 to 2^48 - 1 + */ + public Builder time(long millis) { + this.time = requireTime(millis); + return this; + } + + /** + * Fixes the random field to these bytes, for reproducible ids such as test vectors. This is not + * a source of randomness: an id built from fixed bytes is exactly as guessable as those bytes, + * so production code leaves it unset and lets the byte source draw them. The count must equal + * the request's random width, or generation is refused with {@link + * ErrorCode#RAND_WIDTH_MISMATCH}; the array is copied. + * + * @param rand the random field's bytes + * @return this builder + * @throws NullPointerException when {@code rand} is null + */ + public Builder rand(byte[] rand) { + this.rand = Objects.requireNonNull(rand, "rand").clone(); + return this; + } + + /** + * Sets where random bytes come from; the default is {@link ByteSource#secureRandom()}. The + * source must be cryptographically secure. It is not called when fixed random bytes are set or + * the random width is 0. + * + * @param byteSource the source + * @return this builder + * @throws NullPointerException when {@code byteSource} is null + */ + public Builder byteSource(ByteSource byteSource) { + this.byteSource = Objects.requireNonNull(byteSource, "byteSource"); + return this; + } + + /** + * Returns the options this builder holds. The builder may be changed and built again. + * + * @return the options + */ + public UxidOptions build() { + return new UxidOptions(this); + } + } + + /** Returns {@code millis} when it fits a standard time field, else refuses it. */ + static long requireTime(long millis) { + if (millis < 0 || millis > MAX_TIME) { + throw new IllegalArgumentException( + "the time must be from 0 to 2^48 - 1 milliseconds, got " + millis); + } + return millis; + } +} diff --git a/src/test/java/systems/riddler/uxid/GenerateCorpusTest.java b/src/test/java/systems/riddler/uxid/GenerateCorpusTest.java new file mode 100644 index 0000000..23ac859 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/GenerateCorpusTest.java @@ -0,0 +1,128 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HexFormat; +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/encode.json} and + * the generate refusals of {@code conformance/invalid.json}), each named by its case id. The full + * corpus runner replaces this hand-picked subset. + */ +class GenerateCorpusTest { + + @ParameterizedTest(name = "{0}") // (sabotage: short group padded -> xs, s, m and l cases failed) + @CsvSource( + nullValues = "null", + value = { + "encode/xs-standard-lower, usr, xs, false, lower, '', 1700000000000, usr_01hf7yat00", + "encode/xs-compact-upper, usr, xs, true, upper, 01, 1700000000000, usr_HF7YAT0001", + "encode/s-standard-lower, usr, s, false, lower, 0123, 1700000000000, usr_01hf7yat0004h1", + "encode/s-compact-lower, usr, s, true, lower, 012345, 1700000000000, usr_hf7yat0004hm5", + "encode/m-standard-upper, usr, m, false, upper, 0123456789, 1700000000000," + + " usr_01HF7YAT0004HMASW9", + "encode/m-compact-lower, usr, m, true, lower, 0123456789ab, 1700000000000," + + " usr_hf7yat0004hmasw9n3", + "encode/l-standard-lower, usr, l, false, lower, 0123456789abcd, 1700000000000," + + " usr_01hf7yat0004hmasw9nf61", + "encode/l-compact-upper, usr, l, true, upper, 0123456789abcdef, 1700000000000," + + " usr_HF7YAT0004HMASW9NF6YF", + "encode/xl-standard-lower, usr, xl, false, lower, 0123456789abcdef0123, 1700000000000," + + " usr_01hf7yat0004hmasw9nf6yy093", + "encode/xl-compact-lower, usr, xl, true, lower, 0123456789abcdef0123, 1700000000000," + + " usr_hf7yat0004hmasw9nf6yy093", + "encode/xs-compact-rand-all-ones, cus, xs, true, lower, ff, 1700000000000, cus_hf7yat00z7", + "encode/s-standard-rand-all-ones, cus, s, false, lower, ffff, 1700000000000," + + " cus_01hf7yat00zzz1", + "encode/l-compact-rand-all-ones, cus, l, true, lower, ffffffffffffffff, 1700000000000," + + " cus_hf7yat00zzzzzzzzzzzzf", + "encode/xl-standard-rand-all-ones, cus, xl, false, lower, ffffffffffffffffffff," + + " 1700000000000, cus_01hf7yat00zzzzzzzzzzzzzzzz", + "encode/xs-standard-time-zero, null, xs, false, lower, '', 0, 0000000000", + "encode/xs-standard-time-max, null, xs, false, lower, '', 281474976710655, 7zzzzzzzzz", + "encode/s-compact-guard-last-valid, usr, s, true, lower, 012345, 1065151889407," + + " usr_yzzzzzzz04hm5", + "encode/xl-standard-no-prefix, null, xl, false, lower, 0123456789abcdef0123," + + " 1700000000000, 01hf7yat0004hmasw9nf6yy093", + "encode/xl-standard-empty-prefix, '', xl, false, lower, 0123456789abcdef0123," + + " 1700000000000, 01hf7yat0004hmasw9nf6yy093", + "encode/s-standard-prefix-with-delimiter, a_b, s, false, lower, 0123, 1700000000000," + + " a_b_01hf7yat0004h1", + }) + void anEncodeCaseGivesTheCorpusId( + String id, + String prefix, + String size, + boolean compact, + String letterCase, + String rand, + long time, + String expected) { + UxidOptions.Builder builder = + UxidOptions.builder() + .size(Size.parse(size)) + .compactTime(compact) + .letterCase(letterCase.equals("upper") ? LetterCase.UPPER : LetterCase.LOWER) + .rand(HexFormat.of().parseHex(rand)) + .time(time) + .byteSource(TestSources.refusing()); + if (prefix != null) { + builder.prefix(prefix); + } + assertEquals(expected, Uxid.generate(builder.build()), id); + } + + @ParameterizedTest(name = "{0}") // (sabotage: guard compares to 32 -> both cases threw nothing) + @CsvSource({ + "invalid/generate-compact-time-at-guard, 2164663517184", + "invalid/generate-compact-time-at-guard-previous-window, 1065151889408", + }) + void aCompactTimeAtTheMarkerIsRefused(String id, long time) { + UxidOptions options = + UxidOptions.builder() + .prefix("usr") + .size(Size.S) + .compactTime(true) + .rand(HexFormat.of().parseHex("010203")) + .time(time) + .build(); + UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); + assertEquals(ErrorCode.COMPACT_TIME_OUT_OF_RANGE, refusal.code(), id); + } + + @ParameterizedTest(name = "{0}") // (sabotage: width check never fires -> all three threw nothing) + @CsvSource({ + "invalid/generate-rand-too-wide, s, 010203", + "invalid/generate-rand-too-narrow, xl, 010203040506070809", + "invalid/generate-rand-on-xs-standard, xs, 01", + }) + void fixedBytesOfTheWrongWidthAreRefused(String id, String size, String rand) { + UxidOptions options = + UxidOptions.builder() + .prefix("usr") + .size(Size.parse(size)) + .rand(HexFormat.of().parseHex(rand)) + .time(1_700_000_000_000L) + .build(); + UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); + assertEquals(ErrorCode.RAND_WIDTH_MISMATCH, refusal.code(), id); + } + + @ParameterizedTest(name = "{0}") // (sabotage: cap raised to 11 -> the refusing source was called) + @CsvSource("invalid/generate-rand-size-11, 11") + void aRandomWidthAboveTenIsRefused(String id, int randSize) { + UxidOptions options = + UxidOptions.builder() + .prefix("usr") + .size(Size.XL) + .randSize(randSize) + .time(1_700_000_000_000L) + .byteSource(TestSources.refusing()) + .build(); + UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); + assertEquals(ErrorCode.RAND_SIZE_TOO_LARGE, refusal.code(), id); + } +} diff --git a/src/test/java/systems/riddler/uxid/SizeAndErrorCodeTest.java b/src/test/java/systems/riddler/uxid/SizeAndErrorCodeTest.java new file mode 100644 index 0000000..9953614 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/SizeAndErrorCodeTest.java @@ -0,0 +1,53 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class SizeAndErrorCodeTest { + + @ParameterizedTest(name = "{0} is {1}") // (sabotage: long spellings unread -> 5 cases failed) + @CsvSource({ + "xs, XS", "xsmall, XS", "s, S", "small, S", "m, M", + "medium, M", "l, L", "large, L", "xl, XL", "xlarge, XL", + }) + void bothSpellingsOfASizeParse(String spelling, Size size) { + assertSame(size, assertDoesNotThrow(() -> Size.parse(spelling))); + } + + @Test // (sabotage: parse falls back to XL -> no IllegalArgumentException was thrown) + void anUnknownSpellingIsRefused() { + assertThrows(IllegalArgumentException.class, () -> Size.parse("XL")); + assertThrows(IllegalArgumentException.class, () -> Size.parse("xxl")); + assertThrows(NullPointerException.class, () -> Size.parse(null)); + } + + @Test // (sabotage: XL's wire name xlarge -> the list assertion failed) + void aSizesWireNameIsTheCorpusSpelling() { + List names = Arrays.stream(Size.values()).map(Size::wireName).toList(); + assertEquals(List.of("xs", "s", "m", "l", "xl"), names); + } + + @Test // (sabotage: rand_with_from renamed rand_with_hash -> the set assertion failed) + void theErrorCodesAreTheClosedSetOfGenerationErrors() { + List names = + Arrays.stream(ErrorCode.values()).map(ErrorCode::wireName).collect(Collectors.toList()); + assertEquals( + List.of( + "compact_time_out_of_range", + "rand_width_mismatch", + "rand_size_too_large", + "from_with_monotonic", + "rand_with_from", + "rand_with_monotonic"), + names); + } +} diff --git a/src/test/java/systems/riddler/uxid/TestSources.java b/src/test/java/systems/riddler/uxid/TestSources.java new file mode 100644 index 0000000..da8065b --- /dev/null +++ b/src/test/java/systems/riddler/uxid/TestSources.java @@ -0,0 +1,31 @@ +package systems.riddler.uxid; + +import java.util.ArrayList; +import java.util.List; + +/** Byte sources for tests. */ +final class TestSources { + private TestSources() {} + + /** A source that fails the test if it is ever called. */ + static ByteSource refusing() { + return count -> { + throw new AssertionError("the byte source was called for " + count + " bytes"); + }; + } + + /** A source that answers bytes 0, 1, 2 ... and records each count it was asked for. */ + static final class Counting implements ByteSource { + final List counts = new ArrayList<>(); + + @Override + public byte[] nextBytes(int count) { + counts.add(count); + byte[] bytes = new byte[count]; + for (int i = 0; i < count; i++) { + bytes[i] = (byte) i; + } + return bytes; + } + } +} diff --git a/src/test/java/systems/riddler/uxid/UxidGenerateTest.java b/src/test/java/systems/riddler/uxid/UxidGenerateTest.java new file mode 100644 index 0000000..1fb667e --- /dev/null +++ b/src/test/java/systems/riddler/uxid/UxidGenerateTest.java @@ -0,0 +1,230 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HexFormat; +import java.util.List; +import org.junit.jupiter.api.Test; + +class UxidGenerateTest { + private static final long TIME = 1_700_000_000_000L; + private static final String LOWER = "0123456789abcdefghjkmnpqrstvwxyz"; + + @Test // (sabotage: default size L -> the length assertion failed) + void theDefaultIdIsAnXlBodyWithNoPrefixAtTheClockTime() { + long before = System.currentTimeMillis(); + String id = Uxid.generate(); + long after = System.currentTimeMillis(); + + assertEquals(26, id.length(), id); + assertTrue(id.chars().allMatch(c -> LOWER.indexOf(c) >= 0), id); + long time = standardTime(id); + assertTrue(time >= before && time <= after, id); + } + + @Test // (sabotage: clock read replaced by 0 -> the time range assertion failed) + void anOptionsRequestWithNoTimeReadsTheClockAtTheCall() { + UxidOptions options = UxidOptions.builder().prefix("usr").size(Size.XS).build(); + long before = System.currentTimeMillis(); + String id = Uxid.generate(options); + long after = System.currentTimeMillis(); + + assertTrue(id.startsWith("usr_"), id); + long time = standardTime(id.substring(4)); + assertTrue(time >= before && time <= after, id); + } + + @Test // (sabotage: the draw asks for width + 1 -> the counts assertion failed) + void theByteSourceIsAskedForTheRandomWidthAndFillsTheField() { + TestSources.Counting source = new TestSources.Counting(); + UxidOptions options = + UxidOptions.builder().prefix("cus").size(Size.S).time(TIME).byteSource(source).build(); + + assertEquals("cus_01hf7yat000001", Uxid.generate(options)); + assertEquals(List.of(2), source.counts); + } + + @Test // (sabotage: zero-width shortcut removed -> the refusing source was called) + void theByteSourceIsNotCalledForAZeroWidthField() { + UxidOptions standardXs = + UxidOptions.builder().size(Size.XS).time(TIME).byteSource(TestSources.refusing()).build(); + UxidOptions zeroWidth = + UxidOptions.builder().randSize(0).time(TIME).byteSource(TestSources.refusing()).build(); + + assertEquals("01hf7yat00", Uxid.generate(standardXs)); + assertEquals("01hf7yat00", Uxid.generate(zeroWidth)); + } + + @Test // (sabotage: draw length check never fires -> no IllegalStateException thrown) + void aByteSourceAnsweringTheWrongCountIsRefused() { + UxidOptions shortAnswer = + UxidOptions.builder().time(TIME).byteSource(count -> new byte[count - 1]).build(); + UxidOptions nullAnswer = UxidOptions.builder().time(TIME).byteSource(count -> null).build(); + + assertThrows(IllegalStateException.class, () -> Uxid.generate(shortAnswer)); + assertThrows(IllegalStateException.class, () -> Uxid.generate(nullAnswer)); + } + + @Test // (sabotage: explicit randSize ignored -> the xl width was drawn, id assertion failed) + void anExplicitRandomWidthOverridesTheSize() { + TestSources.Counting source = new TestSources.Counting(); + UxidOptions options = + UxidOptions.builder().size(Size.XL).randSize(3).time(TIME).byteSource(source).build(); + + assertEquals("01hf7yat00000g2", Uxid.generate(options)); + assertEquals(List.of(3), source.counts); + } + + @Test // (sabotage: compact widths made the standard ones -> the counts assertion failed) + void compactTimeGivesTheCompactWidthOfEachSize() { + int[] expected = {1, 3, 6, 8, 10}; + Size[] sizes = Size.values(); + for (int i = 0; i < sizes.length; i++) { + TestSources.Counting source = new TestSources.Counting(); + UxidOptions options = + UxidOptions.builder() + .size(sizes[i]) + .compactTime(true) + .time(TIME) + .byteSource(source) + .build(); + String id = Uxid.generate(options); + assertEquals(List.of(expected[i]), source.counts, sizes[i].wireName()); + assertTrue(id.startsWith("hf7yat00"), id); + } + } + + @Test // (sabotage: draw moved before the compact guard -> the refusing source was called) + void aCompactTimeRefusalComesBeforeAnyDraw() { + UxidOptions options = + UxidOptions.builder() + .size(Size.S) + .compactTime(true) + .time(1_065_151_889_408L) + .byteSource(TestSources.refusing()) + .build(); + + UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options)); + assertEquals(ErrorCode.COMPACT_TIME_OUT_OF_RANGE, refusal.code()); + } + + @Test // (sabotage: standard time also guarded -> assertDoesNotThrow failed) + void aStandardTimeIsNeverRefusedForTheMarker() { + UxidOptions options = + UxidOptions.builder() + .size(Size.XS) + .compactTime(false) + .time(1_065_151_889_408L) + .byteSource(TestSources.refusing()) + .build(); + + assertEquals("00z0000000", assertDoesNotThrow(() -> Uxid.generate(options))); + } + + @Test // (sabotage: letter case applied to the prefix -> the prefix assertion failed) + void letterCaseAppliesToTheBodyAndNeverToThePrefix() { + byte[] rand = HexFormat.of().parseHex("0123"); + UxidOptions upper = + UxidOptions.builder() + .prefix("usr") + .size(Size.S) + .letterCase(LetterCase.UPPER) + .rand(rand) + .time(TIME) + .build(); + UxidOptions lower = + UxidOptions.builder().prefix("Usr").size(Size.S).rand(rand).time(TIME).build(); + + assertEquals("usr_01HF7YAT0004H1", Uxid.generate(upper)); + assertEquals("Usr_01hf7yat0004h1", Uxid.generate(lower)); + } + + @Test // (sabotage: the delimiter option ignored -> the id carried _ and failed) + void theDelimiterJoinsThePrefixAndTheBody() { + byte[] rand = HexFormat.of().parseHex("0123"); + UxidOptions.Builder dash = + UxidOptions.builder().prefix("usr").delimiter("-").size(Size.S).rand(rand).time(TIME); + UxidOptions.Builder twoChars = + UxidOptions.builder().prefix("usr").delimiter("::").size(Size.S).rand(rand).time(TIME); + + assertEquals("usr-01hf7yat0004h1", Uxid.generate(dash.build())); + assertEquals("usr::01hf7yat0004h1", Uxid.generate(twoChars.build())); + } + + @Test // (sabotage: prefix emitted lower-cased -> the multibyte prefix assertion failed) + void thePrefixIsEmittedAsGiven() { + UxidOptions options = UxidOptions.builder().prefix("Café").size(Size.XS).time(TIME).build(); + + assertEquals("Café_01hf7yat00", Uxid.generate(options)); + } + + @Test // (sabotage: rand setter stores the array uncopied -> the id changed, assertion failed) + void fixedBytesAreCopiedWhenSet() { + byte[] rand = HexFormat.of().parseHex("0123"); + UxidOptions.Builder builder = + UxidOptions.builder().prefix("usr").size(Size.S).rand(rand).time(TIME); + UxidOptions options = builder.build(); + rand[0] = (byte) 0xff; + + assertEquals("usr_01hf7yat0004h1", Uxid.generate(options)); + assertEquals("usr_01hf7yat0004h1", Uxid.generate(builder.build())); + } + + @Test // (sabotage: time range check never fires -> no IllegalArgumentException thrown) + void aTimeOutsideFortyEightBitsIsRefusedWhenSet() { + UxidOptions.Builder builder = UxidOptions.builder(); + + assertThrows(IllegalArgumentException.class, () -> builder.time(-1)); + assertThrows(IllegalArgumentException.class, () -> builder.time(1L << 48)); + assertEquals("7zzzzzzzzz", Uxid.generate(builder.size(Size.XS).time((1L << 48) - 1).build())); + } + + @Test // (sabotage: empty delimiter check never fires -> no exception was thrown) + void aSetterRefusesAValueWrongOnItsOwn() { + UxidOptions.Builder builder = UxidOptions.builder(); + + assertThrows(IllegalArgumentException.class, () -> builder.delimiter("")); + assertThrows(IllegalArgumentException.class, () -> builder.randSize(-1)); + assertThrows(NullPointerException.class, () -> builder.prefix(null)); + assertThrows(NullPointerException.class, () -> builder.size(null)); + assertThrows(NullPointerException.class, () -> builder.letterCase(null)); + assertThrows(NullPointerException.class, () -> builder.delimiter(null)); + assertThrows(NullPointerException.class, () -> builder.rand(null)); + assertThrows(NullPointerException.class, () -> builder.byteSource(null)); + } + + @Test // (sabotage: rand_size_too_large as a plain IAE -> the instance check failed) + void aRefusalIsAnIllegalArgumentExceptionCarryingItsCode() { + UxidOptions options = UxidOptions.builder().randSize(11).time(TIME).build(); + + IllegalArgumentException refusal = + assertThrows(IllegalArgumentException.class, () -> Uxid.generate(options)); + assertTrue(refusal instanceof UxidException, refusal.toString()); + assertEquals(ErrorCode.RAND_SIZE_TOO_LARGE, ((UxidException) refusal).code()); + assertTrue(refusal.getMessage().startsWith("rand_size_too_large: "), refusal.getMessage()); + } + + @Test // (sabotage: default source fills a discarded array -> the two draws were equal) + void theDefaultSourceDrawsFreshBytesFromOneSharedInstance() { + ByteSource source = ByteSource.secureRandom(); + byte[] first = source.nextBytes(10); + byte[] second = source.nextBytes(10); + + assertSame(source, ByteSource.secureRandom()); + assertEquals(10, first.length); + assertTrue(!java.util.Arrays.equals(first, second), "two draws of 80 bits were equal"); + } + + /** Reads the standard time from the first 10 characters of a body. */ + private static long standardTime(String body) { + long time = 0; + for (int i = 0; i < 10; i++) { + time = time * 32 + LOWER.indexOf(body.charAt(i)); + } + return time; + } +} From 16d17be2d8318012078a2a4da4793735841a76b6 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 21 Oct 2026 15:46:28 -0600 Subject: [PATCH 2/2] Names the failing assertion in a byte source test's sabotage note Re-running the mutation that makes generate draw width + 1 bytes turns the test red on its id assertion, which runs before the counts assertion, so the note now names the mutation's form and that assertion. Refs: uxjv-94x --- src/test/java/systems/riddler/uxid/UxidGenerateTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/systems/riddler/uxid/UxidGenerateTest.java b/src/test/java/systems/riddler/uxid/UxidGenerateTest.java index 1fb667e..fd8fb68 100644 --- a/src/test/java/systems/riddler/uxid/UxidGenerateTest.java +++ b/src/test/java/systems/riddler/uxid/UxidGenerateTest.java @@ -38,7 +38,7 @@ void anOptionsRequestWithNoTimeReadsTheClockAtTheCall() { assertTrue(time >= before && time <= after, id); } - @Test // (sabotage: the draw asks for width + 1 -> the counts assertion failed) + @Test // (sabotage: draw(source, width + 1) at the call site -> the id assertion failed) void theByteSourceIsAskedForTheRandomWidthAndFillsTheField() { TestSources.Counting source = new TestSources.Counting(); UxidOptions options =