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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ The tree on `main` today holds these agent instructions, the wurk manifest
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 package holds time-based generation
(`Uxid.generate` and `UxidOptions`). Deterministic ids, decoding, the
(`.github/workflows/release.yml`). The package holds time-based and
deterministic generation (`Uxid.generate` and `UxidOptions`). 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.
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/uxjv-iq8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

- `UxidOptions.Builder.from(String)` asks for a deterministic id: the same prefix, input and size always give the same id, a `z` (or `Z`) marker followed by hash characters from SHA-256 at the size's standard length, such as `usr_zcvt7epac0t1ebcsjfyf7cwz25` for `usr` and `alice@example.com` at `xl`.
- A deterministic request does not read the clock, the time form, the random width or the byte source; fixed random bytes with it are refused with `rand_with_from`, and an input or prefix holding an unpaired surrogate, which has no UTF-8 encoding, is refused with `IllegalArgumentException` rather than hashed with a replacement character.
20 changes: 20 additions & 0 deletions src/main/java/systems/riddler/uxid/Base32.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,24 @@ static void appendBytes(StringBuilder out, byte[] bytes, LetterCase letterCase)
out.append(alphabet.charAt(buffer));
}
}

/**
* Appends the first {@code chars x 5} bits of {@code bytes} as {@code chars} characters, with no
* short group; {@code bytes} holds at least that many bits.
*/
static void appendLeadingBits(StringBuilder out, byte[] bytes, int chars, LetterCase letterCase) {
String alphabet = alphabet(letterCase);
int buffer = 0;
int bits = 0;
int next = 0;
for (int i = 0; i < chars; i++) {
if (bits < 5) {
buffer = (buffer << 8) | (bytes[next++] & 0xff);
bits += 8;
}
bits -= 5;
out.append(alphabet.charAt((buffer >>> bits) & 31));
buffer &= (1 << bits) - 1;
}
}
}
31 changes: 25 additions & 6 deletions src/main/java/systems/riddler/uxid/Size.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,41 @@
* <tr><td>{@code xl}</td><td>10</td><td>26</td><td>10</td><td>24</td></tr>
* </table>
*
* <p>A deterministic id ({@link UxidOptions.Builder#from(String)}) has no time or random field: its
* body is the marker and hash characters, at the standard length of its size, whatever the time
* form.
*
* <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),
XS("xs", "xsmall", 0, 1, 9),
/** Small: two random bytes with a standard time, three with a compact time. */
S("s", "small", 2, 3),
S("s", "small", 2, 3, 13),
/** Medium: five random bytes with a standard time, six with a compact time. */
M("m", "medium", 5, 6),
M("m", "medium", 5, 6, 17),
/** Large: seven random bytes with a standard time, eight with a compact time. */
L("l", "large", 7, 8),
L("l", "large", 7, 8, 21),
/** Extra large, the default: ten random bytes with either time form. */
XL("xl", "xlarge", 10, 10);
XL("xl", "xlarge", 10, 10, 25);

private final String wireName;
private final String longName;
private final int standardRandBytes;
private final int compactRandBytes;
private final int hashChars;

Size(String wireName, String longName, int standardRandBytes, int compactRandBytes) {
Size(
String wireName,
String longName,
int standardRandBytes,
int compactRandBytes,
int hashChars) {
this.wireName = wireName;
this.longName = longName;
this.standardRandBytes = standardRandBytes;
this.compactRandBytes = compactRandBytes;
this.hashChars = hashChars;
}

/**
Expand Down Expand Up @@ -81,4 +92,12 @@ public static Size parse(String name) {
int randBytes(boolean compact) {
return compact ? compactRandBytes : standardRandBytes;
}

/**
* The hash characters of a deterministic body at this size, behind the marker: the standard body
* length less one.
*/
int hashChars() {
return hashChars;
}
}
62 changes: 60 additions & 2 deletions src/main/java/systems/riddler/uxid/Uxid.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package systems.riddler.uxid;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
* Generates UXIDs: prefixed, K-sortable identifiers a person can read, in the wire format of the
* Elixir {@code uxid} package.
Expand All @@ -10,9 +13,15 @@
* later millisecond sorts after one made in an earlier millisecond, compared character by
* character.
*
* <p>A deterministic id, asked for with {@link UxidOptions.Builder#from(String)}, has instead a
* body of the marker {@code z} (or {@code Z}) and hash characters: the same prefix, input and size
* always give the same id, and it sorts after every time-based id of its prefix and case.
*
* <pre>{@code
* String id = Uxid.generate(); // a 26-character body, no prefix
* String user = Uxid.generate(UxidOptions.builder().prefix("usr").size(Size.M).build());
* String named =
* Uxid.generate(UxidOptions.builder().prefix("usr").from("alice@example.com").build());
* }</pre>
*
* <p>Every method is static and safe to call from several threads at once; the class holds no
Expand Down Expand Up @@ -44,18 +53,39 @@ public static String generate() {
* 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.
*
* <p>When the options carry a {@link UxidOptions.Builder#from(String) from} input, the id is
* deterministic instead: the digest is SHA-256 over the prefix's UTF-8 bytes, a zero byte, the
* input's UTF-8 bytes, a zero byte and one byte holding the hash character count of the size (9,
* 13, 17, 21 or 25 for {@code xs} to {@code xl}), and the body is the marker followed by the
* first count x 5 bits of the digest, 5 bits per character. The clock, the time form, the random
* width and the byte source are not read.
*
* @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
* bytes whose count is not the random width; {@link ErrorCode#RAND_WITH_FROM} for fixed
* random bytes with a deterministic input, whatever their count
* @throws IllegalArgumentException when the clock reads outside 0 to 2^48 - 1 milliseconds, or,
* for a deterministic id, when the prefix holds an unpaired surrogate and has no UTF-8
* encoding
* @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) {
byte[] from = options.fromUtf8();
if (from != null) {
if (options.hasRand()) {
throw new UxidException(
ErrorCode.RAND_WITH_FROM,
"fixed random bytes and a deterministic input are mutually exclusive: a deterministic"
+ " id has no random field");
}
return deterministic(options, from);
}

Long fixedTime = options.time();
long time = fixedTime == null ? UxidOptions.requireTime(System.currentTimeMillis()) : fixedTime;
boolean compact = Boolean.TRUE.equals(options.compactTime());
Expand Down Expand Up @@ -99,6 +129,34 @@ public static String generate(UxidOptions options) {
return id.toString();
}

private static String deterministic(UxidOptions options, byte[] from) {
int count = options.size().hashChars();
MessageDigest sha256 = sha256();
sha256.update(UxidOptions.utf8(options.prefix(), "the prefix"));
sha256.update((byte) 0);
sha256.update(from);
sha256.update((byte) 0);
sha256.update((byte) count);
byte[] digest = sha256.digest();

StringBuilder id = new StringBuilder(options.prefix().length() + 30);
if (!options.prefix().isEmpty()) {
id.append(options.prefix()).append(options.delimiter());
}
Base32.appendInteger(id, MARKER, 1, options.letterCase());
Base32.appendLeadingBits(id, digest, count, options.letterCase());
return id.toString();
}

/** A new SHA-256 digest; every Java platform is required to provide the algorithm. */
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("this Java platform provides no SHA-256", e);
}
}

private static byte[] draw(ByteSource source, int width) {
byte[] bytes = source.nextBytes(width);
if (bytes == null || bytes.length != width) {
Expand Down
74 changes: 71 additions & 3 deletions src/main/java/systems/riddler/uxid/UxidOptions.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package systems.riddler.uxid;

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
Expand All @@ -9,11 +14,15 @@
* <p>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()}.
* ByteSource#secureRandom()}. Setting {@link Builder#from(String) from} asks for a deterministic id
* instead.
*
* <pre>{@code
* UxidOptions options = UxidOptions.builder().prefix("usr").size(Size.S).build();
* String id = Uxid.generate(options); // usr_ followed by a 14-character body
*
* UxidOptions named = UxidOptions.builder().prefix("usr").from("alice@example.com").build();
* String same = Uxid.generate(named); // usr_zcvt7epac0t1ebcsjfyf7cwz25, every time
* }</pre>
*/
public final class UxidOptions {
Expand All @@ -29,6 +38,7 @@ public final class UxidOptions {
private final Long time;
private final byte[] rand;
private final ByteSource byteSource;
private final byte[] from;

private UxidOptions(Builder builder) {
this.prefix = builder.prefix;
Expand All @@ -40,6 +50,7 @@ private UxidOptions(Builder builder) {
this.time = builder.time;
this.rand = builder.rand == null ? null : builder.rand.clone();
this.byteSource = builder.byteSource;
this.from = builder.from;
}

/**
Expand Down Expand Up @@ -91,6 +102,16 @@ ByteSource byteSource() {
return byteSource;
}

/** The deterministic input as UTF-8, or null for a time-based id. Never handed out. */
byte[] fromUtf8() {
return from;
}

/** Whether fixed random bytes are set, without copying them. */
boolean hasRand() {
return rand != null;
}

/**
* 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
Expand All @@ -107,6 +128,7 @@ public static final class Builder {
private Long time;
private byte[] rand;
private ByteSource byteSource = ByteSource.secureRandom();
private byte[] from;

private Builder() {}

Expand Down Expand Up @@ -233,8 +255,8 @@ public Builder rand(byte[] rand) {

/**
* 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.
* source must be cryptographically secure. It is not called when fixed random bytes are set,
* when the random width is 0, or for a deterministic id.
*
* @param byteSource the source
* @return this builder
Expand All @@ -245,6 +267,31 @@ public Builder byteSource(ByteSource byteSource) {
return this;
}

/**
* Asks for a deterministic id: one derived from {@code from} and the prefix by SHA-256, so the
* same prefix, input and size always give the same id, in either letter case. The body is the
* marker ({@code z}, or {@code Z} in upper case) followed by hash characters, at the size's
* standard body length. The time, the time form, the random width and the byte source are not
* read. Fixed random bytes contradict it and are refused at generation with {@link
* ErrorCode#RAND_WITH_FROM}.
*
* <p>A deterministic id is as guessable as its input: never build one from a secret and treat
* the id as unguessable.
*
* <p>The input is hashed as its UTF-8 bytes. A string with no UTF-8 encoding, one holding an
* unpaired surrogate, is refused here rather than given a replacement character, which would
* give two different strings one id.
*
* @param from the input, any string with a UTF-8 encoding; the empty string is an input too
* @return this builder
* @throws IllegalArgumentException when {@code from} holds an unpaired surrogate
* @throws NullPointerException when {@code from} is null
*/
public Builder from(String from) {
this.from = utf8(Objects.requireNonNull(from, "from"), "from");
return this;
}

/**
* Returns the options this builder holds. The builder may be changed and built again.
*
Expand All @@ -255,6 +302,27 @@ public UxidOptions build() {
}
}

/**
* Returns the UTF-8 bytes of {@code text}, refusing a string with no UTF-8 encoding (an unpaired
* surrogate) rather than replacing it.
*/
static byte[] utf8(String text, String name) {
try {
ByteBuffer encoded =
StandardCharsets.UTF_8
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(text));
byte[] bytes = new byte[encoded.remaining()];
encoded.get(bytes);
return bytes;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(
name + " holds an unpaired surrogate and has no UTF-8 encoding", e);
}
}

/** Returns {@code millis} when it fits a standard time field, else refuses it. */
static long requireTime(long millis) {
if (millis < 0 || millis > MAX_TIME) {
Expand Down
Loading
Loading