Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/uxjv-94x.md
Original file line number Diff line number Diff line change
@@ -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`).
46 changes: 46 additions & 0 deletions src/main/java/systems/riddler/uxid/Base32.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
35 changes: 35 additions & 0 deletions src/main/java/systems/riddler/uxid/ByteSource.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
40 changes: 40 additions & 0 deletions src/main/java/systems/riddler/uxid/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
12 changes: 12 additions & 0 deletions src/main/java/systems/riddler/uxid/LetterCase.java
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 0 additions & 7 deletions src/main/java/systems/riddler/uxid/PackageAnchor.java

This file was deleted.

23 changes: 23 additions & 0 deletions src/main/java/systems/riddler/uxid/SecureRandomSource.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
84 changes: 84 additions & 0 deletions src/main/java/systems/riddler/uxid/Size.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <table>
* <caption>Random bytes and body length per size</caption>
* <tr><th>Size</th><th>Standard random bytes</th><th>Standard length</th>
* <th>Compact random bytes</th><th>Compact length</th></tr>
* <tr><td>{@code xs}</td><td>0</td><td>10</td><td>1</td><td>10</td></tr>
* <tr><td>{@code s}</td><td>2</td><td>14</td><td>3</td><td>13</td></tr>
* <tr><td>{@code m}</td><td>5</td><td>18</td><td>6</td><td>18</td></tr>
* <tr><td>{@code l}</td><td>7</td><td>22</td><td>8</td><td>21</td></tr>
* <tr><td>{@code xl}</td><td>10</td><td>26</td><td>10</td><td>24</td></tr>
* </table>
*
* <p>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;
}
}
113 changes: 113 additions & 0 deletions src/main/java/systems/riddler/uxid/Uxid.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <pre>{@code
* String id = Uxid.generate(); // a 26-character body, no prefix
* String user = Uxid.generate(UxidOptions.builder().prefix("usr").size(Size.M).build());
* }</pre>
*
* <p>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}.
*
* <p>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;
}
}
Loading
Loading