diff --git a/CLAUDE.md b/CLAUDE.md index eb7a9b5..ec6231b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,9 +162,10 @@ 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 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 +deterministic generation (`Uxid.generate` and `UxidOptions`), decoding +(`Uxid.decode`, `DecodeOptions` and `DecodeResult`) and the validity checks +(`Uxid.isValid` and `Uxid.isDeterministic`). 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 diff --git a/changelog.d/uxjv-nyt.md b/changelog.d/uxjv-nyt.md new file mode 100644 index 0000000..b05ad69 --- /dev/null +++ b/changelog.d/uxjv-nyt.md @@ -0,0 +1,6 @@ +### Added + +- `Uxid.decode(String)` and `Uxid.decode(String, DecodeOptions)` read an id back to a `DecodeResult`: a `DecodeResult.Decoded` with the prefix, the size, whether the time is compact, whether the body is deterministic and the time in milliseconds, or a `DecodeResult.Invalid` whose `InvalidReason` is `empty`, `invalid_length` or `invalid_character`, the first rule the body breaks in that order. Malformed input is answered, never thrown. +- `DecodeOptions` takes the delimiter and a `java.time.Clock` that supplies a compact time's high bits; a compact time more than one day ahead of the clock is read from the 2^40 ms window before, which with a clock in the first window after the Unix epoch is a negative time. +- The body lengths 10 and 18 always decode as standard time, so a compact `xs` or compact `m` id decodes to its size, not compact, with a time that is not the one it was generated at. +- `Uxid.isValid(String)` and `Uxid.isValid(String, String delimiter, String requiredPrefix)` answer whether a string is a well-formed id and, given a prefix, carries exactly that prefix; `Uxid.isDeterministic(String)` and `Uxid.isDeterministic(String, String delimiter)` answer whether it is a well-formed deterministic id. Neither reads a clock. diff --git a/src/main/java/systems/riddler/uxid/Base32.java b/src/main/java/systems/riddler/uxid/Base32.java index 6cee47d..1e0de39 100644 --- a/src/main/java/systems/riddler/uxid/Base32.java +++ b/src/main/java/systems/riddler/uxid/Base32.java @@ -14,6 +14,24 @@ private static String alphabet(LetterCase letterCase) { return letterCase == LetterCase.UPPER ? UPPER : LOWER; } + /** + * The 5-bit value of a symbol in either case, or -1 for any other character: the letters {@code + * I}, {@code L}, {@code O} and {@code U} are not symbols and are not read as {@code 1} or {@code + * 0}. + */ + static int value(char c) { + if (c >= 'A' && c <= 'Z') { + c = (char) (c + ('a' - 'A')); + } + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'z') { + return LOWER.indexOf(c); + } + return -1; + } + /** * 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. diff --git a/src/main/java/systems/riddler/uxid/DecodeOptions.java b/src/main/java/systems/riddler/uxid/DecodeOptions.java new file mode 100644 index 0000000..8271ec2 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/DecodeOptions.java @@ -0,0 +1,109 @@ +package systems.riddler.uxid; + +import java.time.Clock; +import java.util.Objects; + +/** + * The options of one {@link Uxid#decode(String, DecodeOptions) decode} call beside the string, + * built with {@link #builder()}. An instance is immutable and safe to share between threads and + * calls. + * + *
The defaults are what {@link Uxid#decode(String)} uses: the delimiter {@code _} and the system + * clock in UTC, {@link Clock#systemUTC()}. + * + *
{@code
+ * DecodeOptions options =
+ * DecodeOptions.builder()
+ * .delimiter("--")
+ * .clock(Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC))
+ * .build();
+ * DecodeResult result = Uxid.decode("usr--hf7yat0004hm5", options);
+ * }
+ */
+public final class DecodeOptions {
+ /** Every default: the delimiter {@code _} and the system clock in UTC. */
+ static final DecodeOptions DEFAULTS = builder().build();
+
+ private final String delimiter;
+ private final Clock clock;
+
+ private DecodeOptions(Builder builder) {
+ this.delimiter = builder.delimiter;
+ this.clock = builder.clock;
+ }
+
+ /**
+ * Returns a builder holding every default.
+ *
+ * @return a new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ String delimiter() {
+ return delimiter;
+ }
+
+ Clock clock() {
+ return clock;
+ }
+
+ /** Refuses a null or empty delimiter, the one check every delimiter-taking call shares. */
+ static String requireDelimiter(String delimiter) {
+ Objects.requireNonNull(delimiter, "delimiter");
+ if (delimiter.isEmpty()) {
+ throw new IllegalArgumentException("the delimiter must not be empty");
+ }
+ return delimiter;
+ }
+
+ /**
+ * Builds a {@link DecodeOptions}. Each setter refuses a value that is wrong on its own. A builder
+ * is not safe to share between threads.
+ */
+ public static final class Builder {
+ private String delimiter = "_";
+ private Clock clock = Clock.systemUTC();
+
+ private Builder() {}
+
+ /**
+ * Sets the delimiter between the prefix and the body; the default is {@code _}. It must be the
+ * one the id was generated with, since an id does not record it. A delimiter made of Crockford
+ * Base32 characters makes the split ambiguous 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) {
+ this.delimiter = requireDelimiter(delimiter);
+ return this;
+ }
+
+ /**
+ * Sets the decoder's clock, which supplies a compact time's high bits; the default is {@link
+ * Clock#systemUTC()}. Only its {@link Clock#millis() millis} are read, once per call, and only
+ * for a compact body. A fixed clock makes the decoding of compact ids reproducible.
+ *
+ * @param clock the clock
+ * @return this builder
+ * @throws NullPointerException when {@code clock} is null
+ */
+ public Builder clock(Clock clock) {
+ this.clock = Objects.requireNonNull(clock, "clock");
+ return this;
+ }
+
+ /**
+ * Builds the options.
+ *
+ * @return the options
+ */
+ public DecodeOptions build() {
+ return new DecodeOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/systems/riddler/uxid/DecodeResult.java b/src/main/java/systems/riddler/uxid/DecodeResult.java
new file mode 100644
index 0000000..001cc59
--- /dev/null
+++ b/src/main/java/systems/riddler/uxid/DecodeResult.java
@@ -0,0 +1,75 @@
+package systems.riddler.uxid;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * What {@link Uxid#decode(String, DecodeOptions) decode} answers for a string: a {@link Decoded}
+ * for a well-formed id, or an {@link Invalid} naming the rule the string broke. Decoding never
+ * throws on malformed input; the caller inspects the result.
+ *
+ * {@code
+ * DecodeResult result = Uxid.decode("cus_01hf7yat0004h1");
+ * if (result instanceof DecodeResult.Decoded decoded) {
+ * decoded.prefix(); // Optional[cus]
+ * decoded.size(); // Size.S
+ * decoded.time(); // OptionalLong[1700000000000]
+ * } else if (result instanceof DecodeResult.Invalid invalid) {
+ * invalid.reason().wireName(); // such as "invalid_length"
+ * }
+ * }
+ */
+public sealed interface DecodeResult permits DecodeResult.Decoded, DecodeResult.Invalid {
+
+ /**
+ * What a well-formed id says about itself: its prefix, the size and time form its body length
+ * gives, whether its body is deterministic, and the time it carries. The random field, and a
+ * deterministic body's hash, are not read back.
+ *
+ * @param prefix everything before the delimiter the body follows, or empty when the id has no
+ * delimiter. An id that starts with the delimiter, which no producer emits, has the prefix
+ * {@code ""}, best read as no prefix
+ * @param size the size the body length gives
+ * @param compact whether the body length is a compact-time one (13, 21 or 24). The lengths 10 and
+ * 18 are always read as standard time, so a compact {@code xs} or compact {@code m} id
+ * decodes with this {@code false}
+ * @param deterministic whether the body starts with the deterministic marker {@code z} or {@code
+ * Z}
+ * @param time the time the body carries, in milliseconds since the Unix epoch, or empty for a
+ * deterministic body, which carries none. A compact time's high bits come from the decoder's
+ * clock, so it can be before the epoch
+ */
+ record Decoded(
+ OptionalThe constants are declared in the order decode checks them: a body that breaks more than one + * rule is refused for the first it breaks. + */ +public enum InvalidReason { + /** The body is empty: an empty string, or one that ends in the delimiter. */ + EMPTY("empty"), + /** + * The body's length in UTF-8 bytes is not one of the body lengths a producer emits: 10, 13, 14, + * 18, 21, 22, 24 or 26. + */ + INVALID_LENGTH("invalid_length"), + /** A body character is not a Crockford Base32 symbol in either case. */ + INVALID_CHARACTER("invalid_character"); + + private final String wireName; + + InvalidReason(String wireName) { + this.wireName = wireName; + } + + /** + * Returns the refusal's name in the wire format's closed set, such as {@code invalid_length}. + * + * @return the wire name + */ + public String wireName() { + return wireName; + } +} diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java index 63578b2..424b8d2 100644 --- a/src/main/java/systems/riddler/uxid/Uxid.java +++ b/src/main/java/systems/riddler/uxid/Uxid.java @@ -2,10 +2,13 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; /** - * Generates UXIDs: prefixed, K-sortable identifiers a person can read, in the wire format of the - * Elixir {@code uxid} package. + * Generates and decodes 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 @@ -24,6 +27,11 @@ * Uxid.generate(UxidOptions.builder().prefix("usr").from("alice@example.com").build()); * } * + *
{@link #decode(String, DecodeOptions) Decoding} reads an id back to what it says about itself + * and answers a {@link DecodeResult} rather than throwing on malformed input; {@link + * #isValid(String, String, String)} and {@link #isDeterministic(String, String)} answer the two + * questions most callers ask of a string. + * *
Every method is static and safe to call from several threads at once; the class holds no * configuration. */ @@ -129,6 +137,142 @@ public static String generate(UxidOptions options) { return id.toString(); } + /** + * Decodes {@code id} under the delimiter {@code _}, reading a compact time against the system + * clock in UTC: {@link #decode(String, DecodeOptions)} with the default {@link DecodeOptions}. + * + * @param id the string to decode + * @return a {@link DecodeResult.Decoded} for a well-formed id, or a {@link DecodeResult.Invalid} + * naming the first rule the string breaks + * @throws NullPointerException when {@code id} is null + */ + public static DecodeResult decode(String id) { + return decode(id, DecodeOptions.DEFAULTS); + } + + /** + * Decodes {@code id} under the options' delimiter, reading a compact time against the options' + * clock. + * + *
The body is everything after the last delimiter a left-to-right scan finds, its matches not + * overlapping, and the prefix everything before it; a string with no delimiter is a body with no + * prefix. Under {@code --}, {@code usr---01hf7yat00} splits after {@code usr}, not at the last + * {@code --}, and its body starts with {@code -}. The body length in UTF-8 bytes gives the size + * and the time form: + * + *
| Body length | Size | Time |
|---|---|---|
| 10 | {@link Size#XS} | standard |
| 13 | {@link Size#S} | compact |
| 14 | {@link Size#S} | standard |
| 18 | {@link Size#M} | standard |
| 21 | {@link Size#L} | compact |
| 22 | {@link Size#L} | standard |
| 24 | {@link Size#XL} | compact |
| 26 | {@link Size#XL} | standard |
The lengths 10 and 18 are also those of a compact {@code xs} and a compact {@code m} body, + * and they are always read as standard time: such an id decodes to its size, not compact, with a + * time read from its first 10 characters, which is not the time it was generated at. + * + *
A body that starts with {@code z} or {@code Z} is deterministic and carries no time. A + * standard time is the first 10 characters, the first one's low 3 bits then nine groups of 5. A + * compact body carries only the low 40 bits of its time in its first 8 characters; the high bits + * are the clock's, less one 2^40 ms window when the result would be more than one day ahead of + * the clock. So the time comes back exact while the id and the clock fall in the same 2^40 ms + * window (about 34.8 years) and the id is no more than one day ahead of the clock. With the clock + * in the first window after the Unix epoch, the window before ends at the epoch, so a time read + * from it is negative: nothing clamps it at zero. The clock is read only for a compact body. + * + *
A string that is not a well-formed id is refused with the first rule its body breaks,
+ * checked in this order: {@link InvalidReason#EMPTY} for an empty body, {@link
+ * InvalidReason#INVALID_LENGTH} for a body whose length in UTF-8 bytes is not in the table above
+ * (an unpaired surrogate counts as three bytes), and {@link InvalidReason#INVALID_CHARACTER} for
+ * a body character that is not a Crockford Base32 symbol in either case. Any string gets an
+ * answer: malformed input never throws.
+ *
+ * @param id the string to decode
+ * @param options the delimiter and the clock
+ * @return a {@link DecodeResult.Decoded} for a well-formed id, or a {@link DecodeResult.Invalid}
+ * naming the first rule the string breaks
+ * @throws NullPointerException when {@code id} or {@code options} is null
+ */
+ public static DecodeResult decode(String id, DecodeOptions options) {
+ Objects.requireNonNull(id, "id");
+ Clock clock = options.clock();
+ return Decoder.decode(id, options.delimiter(), clock::millis);
+ }
+
+ /**
+ * Whether {@code id} is a well-formed id under the delimiter {@code _}: {@link #isValid(String,
+ * String, String)} with no required prefix.
+ *
+ * @param id the string to check
+ * @return whether {@link #decode(String) decode} answers a {@link DecodeResult.Decoded} for it
+ * @throws NullPointerException when {@code id} is null
+ */
+ public static boolean isValid(String id) {
+ return isValid(id, "_", null);
+ }
+
+ /**
+ * Whether {@code id} is a well-formed id under {@code delimiter} and, when {@code requiredPrefix}
+ * is neither null nor empty, carries exactly that prefix: whether {@link #decode(String,
+ * DecodeOptions) decode} answers a {@link DecodeResult.Decoded} for it whose prefix is the one
+ * required. A null or empty required prefix accepts any prefix or none; an id with no delimiter
+ * has no prefix, so it fails any other. Validity does not depend on the clock, so this reads
+ * none.
+ *
+ * @param id the string to check
+ * @param delimiter the delimiter the id was generated with, not empty
+ * @param requiredPrefix the prefix the id must carry, or null or {@code ""} for any
+ * @return whether the id is well formed and carries the required prefix
+ * @throws IllegalArgumentException when {@code delimiter} is empty
+ * @throws NullPointerException when {@code id} or {@code delimiter} is null
+ */
+ public static boolean isValid(String id, String delimiter, String requiredPrefix) {
+ Objects.requireNonNull(id, "id");
+ DecodeResult result = Decoder.decode(id, DecodeOptions.requireDelimiter(delimiter), () -> 0L);
+ if (!(result instanceof DecodeResult.Decoded decoded)) {
+ return false;
+ }
+ return requiredPrefix == null
+ || requiredPrefix.isEmpty()
+ || decoded.prefix().equals(Optional.of(requiredPrefix));
+ }
+
+ /**
+ * Whether {@code id} is a well-formed deterministic id under the delimiter {@code _}: {@link
+ * #isDeterministic(String, String)} with {@code _}.
+ *
+ * @param id the string to check
+ * @return whether the id is well formed and its body starts with the marker
+ * @throws NullPointerException when {@code id} is null
+ */
+ public static boolean isDeterministic(String id) {
+ return isDeterministic(id, "_");
+ }
+
+ /**
+ * Whether {@code id} is a well-formed deterministic id under {@code delimiter}: one {@link
+ * #decode(String, DecodeOptions) decode} answers a {@link DecodeResult.Decoded} for whose body
+ * starts with the marker {@code z} or {@code Z}. A string that is not well formed is not one,
+ * whatever its first body character. It reads no clock.
+ *
+ * @param id the string to check
+ * @param delimiter the delimiter the id was generated with, not empty
+ * @return whether the id is well formed and its body starts with the marker
+ * @throws IllegalArgumentException when {@code delimiter} is empty
+ * @throws NullPointerException when {@code id} or {@code delimiter} is null
+ */
+ public static boolean isDeterministic(String id, String delimiter) {
+ Objects.requireNonNull(id, "id");
+ DecodeResult result = Decoder.decode(id, DecodeOptions.requireDelimiter(delimiter), () -> 0L);
+ return result instanceof DecodeResult.Decoded decoded && decoded.deterministic();
+ }
+
private static String deterministic(UxidOptions options, byte[] from) {
int count = options.size().hashChars();
MessageDigest sha256 = sha256();
diff --git a/src/test/java/systems/riddler/uxid/ClockWindow.java b/src/test/java/systems/riddler/uxid/ClockWindow.java
new file mode 100644
index 0000000..e75ceff
--- /dev/null
+++ b/src/test/java/systems/riddler/uxid/ClockWindow.java
@@ -0,0 +1,30 @@
+package systems.riddler.uxid;
+
+/**
+ * The decoder clocks the compact decode cases of the conformance corpus hold for, computed with the
+ * arithmetic of the corpus README ("Compact times and the decoder's clock") at uxid-ex v2.10.0.
+ */
+final class ClockWindow {
+ /** One day in milliseconds, how far ahead of the clock a compact time may be. */
+ static final long ONE_DAY = 86_400_000L;
+
+ /** The time the corpus's compact cases carry, except the guard case. */
+ static final long CASE_TIME = 1_700_000_000_000L;
+
+ /** The largest compact time field the guard allows, the guard case's low 40 bits. */
+ static final long GUARD_LAST_VALID = 1_065_151_889_407L;
+
+ /**
+ * The first clock that recovers every case, inclusive: a clock no more than one day behind the
+ * cases at {@link #CASE_TIME}.
+ */
+ static final long START = CASE_TIME - ONE_DAY;
+
+ /**
+ * The first clock past the window, exclusive: from here 2^40 + {@link #GUARD_LAST_VALID} is no
+ * longer more than one day ahead of the clock, so the guard case is not stepped back.
+ */
+ static final long END = (1L << 40) + GUARD_LAST_VALID - ONE_DAY;
+
+ private ClockWindow() {}
+}
diff --git a/src/test/java/systems/riddler/uxid/DecodeClockTest.java b/src/test/java/systems/riddler/uxid/DecodeClockTest.java
new file mode 100644
index 0000000..7347e12
--- /dev/null
+++ b/src/test/java/systems/riddler/uxid/DecodeClockTest.java
@@ -0,0 +1,96 @@
+package systems.riddler.uxid;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.OptionalLong;
+import org.junit.jupiter.api.Test;
+
+class DecodeClockTest {
+ private static final String COMPACT_S = "usr_hf7yat0004hm5";
+ private static final String GUARD = "usr_yzzzzzzz04hm5";
+ private static final long WINDOW = 1L << 40;
+
+ @Test // (sabotage: END minus a day dropped -> the END assertion failed)
+ void theWindowEndsAreTheOnesTheCorpusReadmeStates() {
+ assertEquals(1_699_913_600_000L, ClockWindow.START);
+ assertEquals(2_164_577_117_183L, ClockWindow.END);
+ }
+
+ @Test // (sabotage: > ONE_DAY made >= -> the START assertion failed)
+ void theWindowStartIsInclusiveAndTheMillisecondBeforeItMissesTheCase() {
+ assertEquals(OptionalLong.of(ClockWindow.CASE_TIME), timeAt(COMPACT_S, ClockWindow.START));
+ assertEquals(
+ OptionalLong.of(ClockWindow.CASE_TIME - WINDOW), timeAt(COMPACT_S, ClockWindow.START - 1));
+ }
+
+ @Test // (sabotage: > ONE_DAY made >= -> the END - 1 assertion failed)
+ void theWindowEndIsExclusiveAndItsLastMillisecondRecoversTheGuardCase() {
+ assertEquals(OptionalLong.of(ClockWindow.GUARD_LAST_VALID), timeAt(GUARD, ClockWindow.END - 1));
+ assertEquals(
+ OptionalLong.of(WINDOW + ClockWindow.GUARD_LAST_VALID), timeAt(GUARD, ClockWindow.END));
+ }
+
+ @Test // (sabotage: time clamped at 0 -> the negative assertion failed)
+ void aClockInTheFirstWindowStepsBackToANegativeTimeWithNoClamp() {
+ long low = ClockWindow.CASE_TIME - WINDOW;
+ assertEquals(OptionalLong.of(low - WINDOW), timeAt(COMPACT_S, 0));
+ assertEquals(OptionalLong.of(-499_023_255_552L), timeAt(COMPACT_S, 0));
+ assertEquals(OptionalLong.of(low), timeAt(COMPACT_S, low));
+ }
+
+ @Test // (sabotage: floorDiv made / -> the -WINDOW / 2 assertion failed)
+ void aClockBeforeTheEpochReadsFromTheWindowEndingAtTheEpoch() {
+ long low = ClockWindow.CASE_TIME - WINDOW;
+ assertEquals(OptionalLong.of(low - WINDOW), timeAt(COMPACT_S, -1));
+ assertEquals(OptionalLong.of(low - 2 * WINDOW), timeAt(COMPACT_S, -WINDOW / 2));
+ }
+
+ @Test // (sabotage: clock read up front -> the refusing clock failed it)
+ void theClockIsReadOnlyForACompactBody() {
+ DecodeOptions refusing = DecodeOptions.builder().clock(new RefusingClock()).build();
+ assertEquals(
+ OptionalLong.of(ClockWindow.CASE_TIME), decoded("usr_01hf7yat0004h1", refusing).time());
+ assertEquals(OptionalLong.empty(), decoded("cus_zt9vnyfd85bff0", refusing).time());
+ assertInstanceOf(DecodeResult.Invalid.class, Uxid.decode("usr_hf7yat0004hm", refusing));
+ }
+
+ private static OptionalLong timeAt(String id, long now) {
+ DecodeOptions options =
+ DecodeOptions.builder()
+ .clock(Clock.fixed(Instant.ofEpochMilli(now), ZoneOffset.UTC))
+ .build();
+ return decoded(id, options).time();
+ }
+
+ private static DecodeResult.Decoded decoded(String id, DecodeOptions options) {
+ return assertInstanceOf(DecodeResult.Decoded.class, Uxid.decode(id, options), id);
+ }
+
+ /** A clock that fails the test if it is ever read. */
+ private static final class RefusingClock extends Clock {
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ throw new AssertionError("the clock was read");
+ }
+
+ @Override
+ public long millis() {
+ throw new AssertionError("the clock was read");
+ }
+ }
+}
diff --git a/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java b/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java
new file mode 100644
index 0000000..4659499
--- /dev/null
+++ b/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java
@@ -0,0 +1,98 @@
+package systems.riddler.uxid;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+import java.util.OptionalLong;
+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/decode.json} and
+ * the decode refusals of {@code conformance/invalid.json}), each named by its case id. The full
+ * corpus runner replaces this hand-picked subset.
+ */
+class DecodeCorpusTest {
+
+ @ParameterizedTest(name = "{0}") // (sabotage: time read from char 2 -> cases failed)
+ @CsvSource(
+ nullValues = "null",
+ value = {
+ "decode/xl-standard, usr_01hf7yat0004hmasw9nf6yy093, usr, xl, false, false, 1700000000000",
+ "decode/xl-compact, usr_hf7yat0004hmasw9nf6yy093, usr, xl, true, false, 1700000000000",
+ "decode/l-standard, usr_01hf7yat0004hmasw9nf61, usr, l, false, false, 1700000000000",
+ "decode/l-compact, usr_hf7yat0004hmasw9nf6yf, usr, l, true, false, 1700000000000",
+ "decode/m-standard, usr_01hf7yat0004hmasw9, usr, m, false, false, 1700000000000",
+ "decode/m-compact-reads-as-standard, usr_hf7yat0004hmasw9n3, usr, m, false, false,"
+ + " 51950139736068",
+ "decode/s-standard, usr_01hf7yat0004h1, usr, s, false, false, 1700000000000",
+ "decode/s-compact, usr_hf7yat0004hm5, usr, s, true, false, 1700000000000",
+ "decode/xs-standard, usr_01hf7yat00, usr, xs, false, false, 1700000000000",
+ "decode/xs-compact-reads-as-standard, usr_hf7yat0001, usr, xs, false, false,"
+ + " 51950139736065",
+ "decode/xl-standard-upper, usr_01HF7YAT0004HMASW9NF6YY093, usr, xl, false, false,"
+ + " 1700000000000",
+ "decode/xl-standard-no-prefix, 01hf7yat0004hmasw9nf6yy093, null, xl, false, false,"
+ + " 1700000000000",
+ "decode/s-standard-prefix-with-delimiter, a_b_01hf7yat000001, a_b, s, false, false,"
+ + " 1700000000000",
+ "decode/s-compact-guard-last-valid, usr_yzzzzzzz04hm5, usr, s, true, false, 1065151889407",
+ "decode/deterministic-usr-xl, usr_zcvt7epac0t1ebcsjfyf7cwz25, usr, xl, false, true, null",
+ "decode/deterministic-cus-s, cus_zt9vnyfd85bff0, cus, s, false, true, null",
+ "decode/deterministic-no-prefix-m, zqz4cwzgyw8ks97869, null, m, false, true, null",
+ "decode/deterministic-upper-marker, usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25, usr, xl, false, true,"
+ + " null",
+ })
+ void aDecodeCaseReadsTheCorpusAnswerAcrossTheClockWindow(
+ String id,
+ String input,
+ String prefix,
+ String size,
+ boolean compact,
+ boolean deterministic,
+ Long time) {
+ DecodeResult.Decoded expected =
+ new DecodeResult.Decoded(
+ Optional.ofNullable(prefix),
+ Size.parse(size),
+ compact,
+ deterministic,
+ time == null ? OptionalLong.empty() : OptionalLong.of(time));
+ for (long now : new long[] {ClockWindow.START, ClockWindow.END - 1}) {
+ DecodeOptions options =
+ DecodeOptions.builder()
+ .clock(Clock.fixed(Instant.ofEpochMilli(now), ZoneOffset.UTC))
+ .build();
+ assertEquals(expected, Uxid.decode(input, options), id + " at " + now);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}") // (sabotage: 2-byte char as 1 byte -> non-ascii cases failed)
+ @CsvSource({
+ "invalid/decode-empty-string, '', empty",
+ "invalid/decode-ends-in-delimiter, usr_, empty",
+ "invalid/decode-prefix-with-delimiter-ends-in-delimiter, a_b_, empty",
+ "invalid/decode-delimiter-only, _, empty",
+ "invalid/decode-length-11, usr_01hf7yat000, invalid_length",
+ "invalid/decode-length-25, usr_01hf7yat00000g40r40m30e20, invalid_length",
+ "invalid/decode-length-27, usr_01hf7yat00000g40r40m30e2090, invalid_length",
+ "invalid/decode-no-delimiter-short, usr, invalid_length",
+ "invalid/decode-character-in-time, usr_01hu7yat00000g40r40m30e209, invalid_character",
+ "invalid/decode-character-in-random, usr_01hf7yat00000g4lr40m30e209, invalid_character",
+ "invalid/decode-upper-o-in-random, usr_01hf7yat00000g40r40mO0e209, invalid_character",
+ "invalid/decode-hyphen-in-body, usr_01hf7yat00-00g40r40m30e209, invalid_character",
+ "invalid/decode-character-in-deterministic, usr_zcvt7epac0t1ebcsjfyf7cwzi5, invalid_character",
+ "invalid/decode-non-ascii-table-length, 01hf7yat00000g40r40m30e2é, invalid_character",
+ "invalid/decode-non-ascii-off-table-length, 01hf7yat00000g40r40m30e20é, invalid_length",
+ "invalid/decode-length-and-character, usr_01hf7yat00u, invalid_length",
+ })
+ void aDecodeRefusalCaseIsRefusedWithTheCorpusReason(String id, String input, String reason) {
+ DecodeResult.Invalid invalid =
+ assertInstanceOf(DecodeResult.Invalid.class, Uxid.decode(input), id);
+ assertEquals(reason, invalid.reason().wireName(), id);
+ }
+}
diff --git a/src/test/java/systems/riddler/uxid/UxidDecodeTest.java b/src/test/java/systems/riddler/uxid/UxidDecodeTest.java
new file mode 100644
index 0000000..7782a56
--- /dev/null
+++ b/src/test/java/systems/riddler/uxid/UxidDecodeTest.java
@@ -0,0 +1,156 @@
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalLong;
+import org.junit.jupiter.api.Test;
+
+class UxidDecodeTest {
+ private static final long TIME = 1_700_000_000_000L;
+ private static final Clock AT_TIME = Clock.fixed(Instant.ofEpochMilli(TIME), ZoneOffset.UTC);
+
+ @Test // (sabotage: wrong wire name on INVALID_CHARACTER -> it failed)
+ void theReasonsAreTheWireNamesInRefusalOrder() {
+ assertEquals(
+ List.of(InvalidReason.EMPTY, InvalidReason.INVALID_LENGTH, InvalidReason.INVALID_CHARACTER),
+ List.of(InvalidReason.values()));
+ assertEquals("empty", InvalidReason.EMPTY.wireName());
+ assertEquals("invalid_length", InvalidReason.INVALID_LENGTH.wireName());
+ assertEquals("invalid_character", InvalidReason.INVALID_CHARACTER.wireName());
+ }
+
+ @Test // (sabotage: surrogate bound check removed -> assertDoesNotThrow failed)
+ void malformedInputIsAnsweredNeverThrown() {
+ List