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. + * + *
| Size | Standard random bytes | Standard length | + *Compact random bytes | Compact length |
|---|---|---|---|---|
| {@code xs} | 0 | 10 | 1 | 10 |
| {@code s} | 2 | 14 | 3 | 13 |
| {@code m} | 5 | 18 | 6 | 18 |
| {@code l} | 7 | 22 | 8 | 21 |
| {@code xl} | 10 | 26 | 10 | 24 |
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