diff --git a/CLAUDE.md b/CLAUDE.md index fa510f9..eb7a9b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/changelog.d/uxjv-iq8.md b/changelog.d/uxjv-iq8.md new file mode 100644 index 0000000..f24b1bc --- /dev/null +++ b/changelog.d/uxjv-iq8.md @@ -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. diff --git a/src/main/java/systems/riddler/uxid/Base32.java b/src/main/java/systems/riddler/uxid/Base32.java index 1ff8433..6cee47d 100644 --- a/src/main/java/systems/riddler/uxid/Base32.java +++ b/src/main/java/systems/riddler/uxid/Base32.java @@ -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; + } + } } diff --git a/src/main/java/systems/riddler/uxid/Size.java b/src/main/java/systems/riddler/uxid/Size.java index ed21173..d51dea7 100644 --- a/src/main/java/systems/riddler/uxid/Size.java +++ b/src/main/java/systems/riddler/uxid/Size.java @@ -21,30 +21,41 @@ *
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. + * *
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; } /** @@ -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; + } } diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java index f1a3ca3..63578b2 100644 --- a/src/main/java/systems/riddler/uxid/Uxid.java +++ b/src/main/java/systems/riddler/uxid/Uxid.java @@ -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. @@ -10,9 +13,15 @@ * later millisecond sorts after one made in an earlier millisecond, compared character by * character. * + *
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. + * *
{@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());
* }
*
* Every method is static and safe to call from several threads at once; the class holds no @@ -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. * + *
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()); @@ -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) { diff --git a/src/main/java/systems/riddler/uxid/UxidOptions.java b/src/main/java/systems/riddler/uxid/UxidOptions.java index 7605fae..d89a305 100644 --- a/src/main/java/systems/riddler/uxid/UxidOptions.java +++ b/src/main/java/systems/riddler/uxid/UxidOptions.java @@ -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; /** @@ -9,11 +14,15 @@ *
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. * *
{@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
* }
*/
public final class UxidOptions {
@@ -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;
@@ -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;
}
/**
@@ -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
@@ -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() {}
@@ -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
@@ -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}.
+ *
+ * A deterministic id is as guessable as its input: never build one from a secret and treat + * the id as unguessable. + * + *
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. * @@ -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) { diff --git a/src/test/java/systems/riddler/uxid/DeterministicCorpusTest.java b/src/test/java/systems/riddler/uxid/DeterministicCorpusTest.java new file mode 100644 index 0000000..28e6bcf --- /dev/null +++ b/src/test/java/systems/riddler/uxid/DeterministicCorpusTest.java @@ -0,0 +1,67 @@ +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/deterministic.json} and the deterministic refusal of {@code + * conformance/invalid.json}), each named by its case id. The full corpus runner replaces this + * hand-picked subset. + */ +class DeterministicCorpusTest { + + @ParameterizedTest(name = "{0}") // (sabotage: count byte left out -> every case failed) + @CsvSource( + nullValues = "null", + value = { + "deterministic/golden-usr-xl, usr, alice@example.com, xl, lower," + + " usr_zcvt7epac0t1ebcsjfyf7cwz25", + "deterministic/golden-cus-s, cus, acme-corp, s, lower, cus_zt9vnyfd85bff0", + "deterministic/golden-no-prefix-m, null, alice@example.com, m, lower, zqz4cwzgyw8ks97869", + "deterministic/usr-xs, usr, alice@example.com, xs, lower, usr_z1yhw20vag", + "deterministic/usr-s, usr, alice@example.com, s, lower, usr_z5rtsrkhx2h4dr", + "deterministic/usr-m, usr, alice@example.com, m, lower, usr_zt8naccefxhwnrmscd", + "deterministic/usr-l, usr, alice@example.com, l, lower, usr_zwbz6myvgz7ehe3nceap5n", + "deterministic/usr-xl, usr, alice@example.com, xl, lower, usr_zcvt7epac0t1ebcsjfyf7cwz25", + "deterministic/empty-prefix-xl, '', alice@example.com, xl, lower," + + " z4krgegf6drn563pvqhya4tsx9", + "deterministic/no-prefix-xl, null, alice@example.com, xl, lower," + + " z4krgegf6drn563pvqhya4tsx9", + "deterministic/usr-xl-upper, usr, alice@example.com, xl, upper," + + " usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25", + "deterministic/non-ascii-from-m, cus, café, m, lower, cus_zfsx9se75671f9kdjv", + }) + void aDeterministicCaseGivesTheCorpusId( + String id, String prefix, String from, String size, String letterCase, String expected) { + UxidOptions.Builder builder = + UxidOptions.builder() + .from(from) + .size(Size.parse(size)) + .letterCase(letterCase.equals("upper") ? LetterCase.UPPER : LetterCase.LOWER) + .byteSource(TestSources.refusing()); + if (prefix != null) { + builder.prefix(prefix); + } + assertEquals(expected, Uxid.generate(builder.build()), id); + } + + @ParameterizedTest(name = "{0}") // (sabotage: rand_with_from check removed -> nothing thrown) + @CsvSource("invalid/generate-rand-with-from, usr, alice@example.com, 00010203040506070809") + void fixedBytesWithADeterministicBodyAreRefused( + String id, String prefix, String from, String rand) { + UxidOptions options = + UxidOptions.builder() + .prefix(prefix) + .from(from) + .rand(HexFormat.of().parseHex(rand)) + .byteSource(TestSources.refusing()) + .build(); + UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); + assertEquals(ErrorCode.RAND_WITH_FROM, refusal.code(), id); + } +} diff --git a/src/test/java/systems/riddler/uxid/UxidDeterministicTest.java b/src/test/java/systems/riddler/uxid/UxidDeterministicTest.java new file mode 100644 index 0000000..2d9db16 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/UxidDeterministicTest.java @@ -0,0 +1,103 @@ +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import org.junit.jupiter.api.Test; + +class UxidDeterministicTest { + + private static final String ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; + + /** + * The body the wire format gives, computed here a second way: the hash input laid out byte by + * byte, the digest read as one unsigned number and its top {@code count x 5} bits cut into + * characters. + */ + private static String expectedBody(String prefix, String from, int count) + throws NoSuchAlgorithmException { + ByteArrayOutputStream input = new ByteArrayOutputStream(); + input.writeBytes(prefix.getBytes(StandardCharsets.UTF_8)); + input.write(0); + input.writeBytes(from.getBytes(StandardCharsets.UTF_8)); + input.write(0); + input.write(count); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()); + BigInteger top = new BigInteger(1, digest).shiftRight(256 - 5 * count); + StringBuilder body = new StringBuilder("z"); + for (int i = count - 1; i >= 0; i--) { + body.append(ALPHABET.charAt(top.shiftRight(5 * i).intValue() & 31)); + } + return body.toString(); + } + + @Test // (sabotage: digest bits read from the low end -> the body assertion failed) + void theBodyIsTheMarkerThenTheDigestsFirstBitsForEachSize() throws Exception { + int[] counts = {9, 13, 17, 21, 25}; + Size[] sizes = {Size.XS, Size.S, Size.M, Size.L, Size.XL}; + for (int i = 0; i < sizes.length; i++) { + UxidOptions options = + UxidOptions.builder().prefix("cus").from("user-42@example.com").size(sizes[i]).build(); + assertEquals( + "cus_" + expectedBody("cus", "user-42@example.com", counts[i]), + Uxid.generate(options), + sizes[i].wireName()); + } + } + + @Test // (sabotage: from branch moved below the width cap -> assertDoesNotThrow failed) + void aDeterministicIdReadsNoTimeWidthTimeFormOrByteSource() { + UxidOptions plain = UxidOptions.builder().prefix("usr").from("alice@example.com").build(); + UxidOptions loaded = + UxidOptions.builder() + .prefix("usr") + .from("alice@example.com") + .time(2_164_663_517_184L) + .compactTime(true) + .randSize(11) + .byteSource(TestSources.refusing()) + .build(); + assertEquals("usr_zcvt7epac0t1ebcsjfyf7cwz25", Uxid.generate(plain)); + assertEquals("usr_zcvt7epac0t1ebcsjfyf7cwz25", assertDoesNotThrow(() -> Uxid.generate(loaded))); + } + + @Test // (sabotage: width check moved before rand_with_from -> the code assertion failed) + void fixedBytesOfAnyWidthAreRefusedWithADeterministicBody() { + for (byte[] rand : new byte[][] {new byte[0], new byte[3], new byte[10]}) { + UxidOptions options = + UxidOptions.builder().prefix("usr").from("alice@example.com").rand(rand).build(); + UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options)); + assertEquals(ErrorCode.RAND_WITH_FROM, refusal.code(), rand.length + " bytes"); + } + } + + @Test // (sabotage: the prefix left out of the hash input -> the two bodies were equal) + void theSameFromUnderTwoPrefixesGivesUnrelatedBodies() { + String usr = Uxid.generate(UxidOptions.builder().prefix("usr").from("x").build()); + String cus = Uxid.generate(UxidOptions.builder().prefix("cus").from("x").build()); + assertNotEquals(usr.substring(4), cus.substring(4)); + } + + @Test // (sabotage: from encoded by String.getBytes -> no IllegalArgumentException thrown) + void aFromWithNoUtf8EncodingIsRefusedWhenSet() { + UxidOptions.Builder builder = UxidOptions.builder(); + assertThrows(IllegalArgumentException.class, () -> builder.from("ab\uD800")); + assertThrows(IllegalArgumentException.class, () -> builder.from("\uDC00cd")); + assertThrows(NullPointerException.class, () -> builder.from(null)); + } + + @Test // (sabotage: prefix encoded by String.getBytes -> no IllegalArgumentException thrown) + void aPrefixWithNoUtf8EncodingIsRefusedForADeterministicId() { + UxidOptions options = UxidOptions.builder().prefix("us\uD800").from("x").build(); + IllegalArgumentException refusal = + assertThrows(IllegalArgumentException.class, () -> Uxid.generate(options)); + assertEquals(IllegalArgumentException.class, refusal.getClass()); + } +}