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( + Optional prefix, Size size, boolean compact, boolean deterministic, OptionalLong time) + implements DecodeResult { + + /** + * Creates a decoded value. + * + * @throws NullPointerException when {@code prefix}, {@code size} or {@code time} is null + */ + public Decoded { + Objects.requireNonNull(prefix, "prefix"); + Objects.requireNonNull(size, "size"); + Objects.requireNonNull(time, "time"); + } + } + + /** + * A refused string, with the first rule it broke. + * + * @param reason the rule the body broke + */ + record Invalid(InvalidReason reason) implements DecodeResult { + + /** + * Creates a refusal. + * + * @throws NullPointerException when {@code reason} is null + */ + public Invalid { + Objects.requireNonNull(reason, "reason"); + } + } +} diff --git a/src/main/java/systems/riddler/uxid/Decoder.java b/src/main/java/systems/riddler/uxid/Decoder.java new file mode 100644 index 0000000..b76a7c4 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Decoder.java @@ -0,0 +1,134 @@ +package systems.riddler.uxid; + +import java.util.Optional; +import java.util.OptionalLong; +import java.util.function.LongSupplier; + +/** Reads a string to a {@link DecodeResult} under the wire format's decoding rules. */ +final class Decoder { + /** The span of a compact time field, 2^40 milliseconds. */ + static final long COMPACT_WINDOW = 1L << 40; + + /** + * How far ahead of the decoder's clock a compact time may be before it is read as one from the + * window before: one day in milliseconds. + */ + static final long ONE_DAY = 86_400_000L; + + private Decoder() {} + + /** + * Decodes {@code id} under {@code delimiter}, a non-empty string, calling {@code clock} only for + * a compact body. + */ + static DecodeResult decode(String id, String delimiter, LongSupplier clock) { + int at = lastDelimiter(id, delimiter); + Optional prefix = at < 0 ? Optional.empty() : Optional.of(id.substring(0, at)); + String body = at < 0 ? id : id.substring(at + delimiter.length()); + + if (body.isEmpty()) { + return new DecodeResult.Invalid(InvalidReason.EMPTY); + } + int length = utf8Length(body); + Size size = sizeOf(length); + if (size == null) { + return new DecodeResult.Invalid(InvalidReason.INVALID_LENGTH); + } + for (int i = 0; i < body.length(); i++) { + if (Base32.value(body.charAt(i)) < 0) { + return new DecodeResult.Invalid(InvalidReason.INVALID_CHARACTER); + } + } + + boolean compact = length == 13 || length == 21 || length == 24; + char first = body.charAt(0); + boolean deterministic = first == 'z' || first == 'Z'; + OptionalLong time; + if (deterministic) { + time = OptionalLong.empty(); + } else if (compact) { + time = OptionalLong.of(compactTime(read(body, 0, 8), clock.getAsLong())); + } else { + time = OptionalLong.of((read(body, 0, 1) & 7) << 45 | read(body, 1, 10)); + } + return new DecodeResult.Decoded(prefix, size, compact, deterministic, time); + } + + /** + * The index of the last delimiter a left-to-right scan finds, its matches not overlapping, or -1 + * when there is none. Under {@code --}, {@code a---b} splits after {@code a}, as the reference's + * split does, not at the last {@code --}. + */ + private static int lastDelimiter(String id, String delimiter) { + int at = -1; + int from = 0; + while (true) { + int found = id.indexOf(delimiter, from); + if (found < 0) { + return at; + } + at = found; + from = found + delimiter.length(); + } + } + + /** + * The length of {@code text} in UTF-8 bytes. An unpaired surrogate counts as three bytes, the + * width of the replacement character U+FFFD. + */ + private static int utf8Length(String text) { + int bytes = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c < 0x80) { + bytes += 1; + } else if (c < 0x800) { + bytes += 2; + } else if (Character.isHighSurrogate(c) + && i + 1 < text.length() + && Character.isLowSurrogate(text.charAt(i + 1))) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } + return bytes; + } + + /** The size a body length gives, or null for a length a producer never emits. */ + private static Size sizeOf(int length) { + return switch (length) { + case 10 -> Size.XS; + case 13, 14 -> Size.S; + case 18 -> Size.M; + case 21, 22 -> Size.L; + case 24, 26 -> Size.XL; + default -> null; + }; + } + + /** Reads the symbols from {@code start} to {@code end} as one integer, 5 bits each. */ + private static long read(String body, int start, int end) { + long value = 0; + for (int i = start; i < end; i++) { + value = value << 5 | Base32.value(body.charAt(i)); + } + return value; + } + + /** + * The time of the compact field {@code low} under the clock {@code now}: the clock's bits above + * bit 40 with {@code low}, less one window when that is more than one day ahead of the clock. The + * window is the clock's rounded down, so with the clock in the first window after the epoch (or + * before the epoch) the window before ends at or before the epoch and the time can be negative; + * nothing clamps it at zero. + */ + static long compactTime(long low, long now) { + long time = Math.floorDiv(now, COMPACT_WINDOW) * COMPACT_WINDOW + low; + if (time - now > ONE_DAY) { + time -= COMPACT_WINDOW; + } + return time; + } +} diff --git a/src/main/java/systems/riddler/uxid/InvalidReason.java b/src/main/java/systems/riddler/uxid/InvalidReason.java new file mode 100644 index 0000000..30fcbe3 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/InvalidReason.java @@ -0,0 +1,36 @@ +package systems.riddler.uxid; + +/** + * Why {@link Uxid#decode(String, DecodeOptions) decode} refused a string: the decode refusals 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. + * + *

The 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 to size and time form
Body lengthSizeTime
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 inputs = + List.of( + "\ud800", + "usr_\udc00", + "usr_01hf7yat0\ud83d\ude00", + "usr_01hf7yat00\ud83d", + "__________", + "usr_" + "0".repeat(10_000), + "usr_01hf7yat0\u0000", + "usr_\u00e9\u00e9\u00e9\u00e9\u00e9"); + for (String input : inputs) { + DecodeResult result = assertDoesNotThrow(() -> Uxid.decode(input), input); + assertInstanceOf(DecodeResult.Invalid.class, result, input); + } + } + + @Test // (sabotage: 2-byte char counted as 1 -> the first assertion failed) + void theBodyLengthIsCountedInUtf8Bytes() { + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7yat\u00e9")); + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7ya\u20ac")); + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7y\ud83d\ude00")); + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7ya\ud800")); + } + + @Test // (sabotage: length 21 not compact -> the l compact assertion failed) + void aGeneratedIdDecodesToItsSizeAndTimeForm() { + for (Size size : Size.values()) { + for (boolean compact : new boolean[] {false, true}) { + String id = + Uxid.generate( + UxidOptions.builder() + .prefix("cus") + .size(size) + .compactTime(compact) + .time(TIME) + .build()); + DecodeResult.Decoded decoded = decoded(id); + boolean ambiguous = compact && (size == Size.XS || size == Size.M); + assertEquals(size, decoded.size(), id); + assertEquals(compact && !ambiguous, decoded.compact(), id); + assertEquals(Optional.of("cus"), decoded.prefix(), id); + assertFalse(decoded.deterministic(), id); + if (!ambiguous) { + assertEquals(OptionalLong.of(TIME), decoded.time(), id); + } + } + } + } + + @Test // (sabotage: length 18 read compact -> the compact assertion failed) + void bodyLengthsTenAndEighteenDecodeToTheStandardReading() { + for (Size size : List.of(Size.XS, Size.M)) { + String id = + Uxid.generate(UxidOptions.builder().size(size).compactTime(true).time(TIME).build()); + DecodeResult.Decoded decoded = decoded(id); + assertEquals(size, decoded.size(), id); + assertFalse(decoded.compact(), id); + long standard = (Base32.value(id.charAt(0)) & 7L) << 45; + for (int i = 1; i < 10; i++) { + standard |= (long) Base32.value(id.charAt(i)) << (5 * (9 - i)); + } + assertEquals(OptionalLong.of(standard), decoded.time(), id); + } + } + + @Test // (sabotage: upper-case folding removed -> the equality failed) + void eitherCaseDecodesToTheSameValue() { + assertEquals(decoded("usr_hf7yat0004hm5"), decoded("usr_HF7YAT0004HM5")); + assertEquals(decoded("usr_01hf7yat0004h1"), decoded("usr_01Hf7YaT0004h1")); + } + + @Test // (sabotage: scan resumed at found + 1 -> the --- assertion failed) + void theDelimiterIsTheOptionsOneSplitWithoutOverlap() { + DecodeOptions dashes = DecodeOptions.builder().delimiter("--").clock(AT_TIME).build(); + DecodeResult.Decoded decoded = decoded("usr--01hf7yat00", dashes); + assertEquals(Optional.of("usr"), decoded.prefix()); + assertEquals(Optional.of("a--b"), decoded("a--b--01hf7yat00", dashes).prefix()); + assertEquals(reason(InvalidReason.INVALID_LENGTH), Uxid.decode("usr---01hf7yat00", dashes)); + assertEquals(reason(InvalidReason.INVALID_LENGTH), Uxid.decode("usr--01hf7yat00")); + } + + @Test // (sabotage: at <= 0 read as no prefix -> the first assertion failed) + void anIdThatStartsWithTheDelimiterHasTheEmptyPrefix() { + assertEquals(Optional.of(""), decoded("_01hf7yat00").prefix()); + assertEquals(Optional.empty(), decoded("01hf7yat00").prefix()); + } + + @Test // (sabotage: clock null check removed -> assertThrows failed) + void theOptionsRefuseAnEmptyOrNullDelimiterAndANullClock() { + assertThrows(IllegalArgumentException.class, () -> DecodeOptions.builder().delimiter("")); + assertThrows(NullPointerException.class, () -> DecodeOptions.builder().delimiter(null)); + assertThrows(NullPointerException.class, () -> DecodeOptions.builder().clock(null)); + assertThrows(NullPointerException.class, () -> Uxid.decode(null)); + assertThrows(NullPointerException.class, () -> Uxid.decode("01hf7yat00", null)); + } + + @Test // (sabotage: size null check removed -> assertThrows failed) + void theResultRecordsRefuseNullComponents() { + assertThrows(NullPointerException.class, () -> new DecodeResult.Invalid(null)); + assertThrows( + NullPointerException.class, + () -> new DecodeResult.Decoded(null, Size.S, false, false, OptionalLong.empty())); + assertThrows( + NullPointerException.class, + () -> new DecodeResult.Decoded(Optional.empty(), null, false, false, OptionalLong.empty())); + assertThrows( + NullPointerException.class, + () -> new DecodeResult.Decoded(Optional.empty(), Size.S, false, false, null)); + assertTrue(new DecodeResult.Invalid(InvalidReason.EMPTY).equals(reason(InvalidReason.EMPTY))); + } + + private static DecodeResult reason(InvalidReason reason) { + return new DecodeResult.Invalid(reason); + } + + private static DecodeResult.Decoded decoded(String id) { + return decoded(id, DecodeOptions.builder().clock(AT_TIME).build()); + } + + private static DecodeResult.Decoded decoded(String id, DecodeOptions options) { + return assertInstanceOf(DecodeResult.Decoded.class, Uxid.decode(id, options), id); + } +} diff --git a/src/test/java/systems/riddler/uxid/UxidValidityTest.java b/src/test/java/systems/riddler/uxid/UxidValidityTest.java new file mode 100644 index 0000000..ba3fa16 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/UxidValidityTest.java @@ -0,0 +1,68 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class UxidValidityTest { + + @Test // (sabotage: an Invalid answered true -> a malformed assertion failed) + void aWellFormedIdIsValidAndAMalformedOneIsNot() { + assertTrue(Uxid.isValid("cus_01hf7yat0004h1")); + assertTrue(Uxid.isValid("cus_hf7yat0004hm5")); + assertTrue(Uxid.isValid("usr_zcvt7epac0t1ebcsjfyf7cwz25")); + assertFalse(Uxid.isValid("cus_01hf7yat0004h12")); + assertFalse(Uxid.isValid("cus_01hu7yat0004h1")); + assertFalse(Uxid.isValid("cus_")); + assertFalse(Uxid.isValid("")); + } + + @Test // (sabotage: any present prefix accepted -> the usr assertion failed) + void aRequiredPrefixMustBeTheIdsPrefix() { + assertTrue(Uxid.isValid("cus_01hf7yat0004h1", "_", "cus")); + assertFalse(Uxid.isValid("cus_01hf7yat0004h1", "_", "usr")); + assertFalse(Uxid.isValid("01hf7yat0004h1", "_", "cus")); + assertFalse(Uxid.isValid("a_cus_01hf7yat0004h1", "_", "cus")); + assertTrue(Uxid.isValid("a_cus_01hf7yat0004h1", "_", "a_cus")); + } + + @Test // (sabotage: isEmpty check removed -> the empty prefix assertion failed) + void aNullOrEmptyRequiredPrefixAcceptsAnyPrefixOrNone() { + assertTrue(Uxid.isValid("cus_01hf7yat0004h1", "_", null)); + assertTrue(Uxid.isValid("cus_01hf7yat0004h1", "_", "")); + assertTrue(Uxid.isValid("01hf7yat0004h1", "_", "")); + assertFalse(Uxid.isValid("cus_01hf7yat0004h12", "_", null)); + } + + @Test // (sabotage: delimiter replaced by _ -> the -- assertion failed) + void validityReadsTheDelimiterGiven() { + assertTrue(Uxid.isValid("cus--01hf7yat0004h1", "--", "cus")); + assertFalse(Uxid.isValid("cus--01hf7yat0004h1", "_", null)); + assertThrows( + IllegalArgumentException.class, () -> Uxid.isValid("cus_01hf7yat0004h1", "", null)); + assertThrows(NullPointerException.class, () -> Uxid.isValid(null)); + } + + @Test // (sabotage: !compact() for deterministic() -> a false assertion failed) + void aDeterministicIdIsAWellFormedOneStartingWithTheMarker() { + assertTrue(Uxid.isDeterministic("usr_zcvt7epac0t1ebcsjfyf7cwz25")); + assertTrue(Uxid.isDeterministic("usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25")); + assertTrue(Uxid.isDeterministic("zqz4cwzgyw8ks97869")); + assertFalse(Uxid.isDeterministic("usr_01hf7yat0004h1")); + assertFalse(Uxid.isDeterministic("usr_zcvt7epac")); + assertFalse(Uxid.isDeterministic("usr_")); + } + + @Test // (sabotage: delimiter replaced by _ -> the -- assertion failed) + void deterministicReadsTheDelimiterGiven() { + assertTrue(Uxid.isDeterministic("usr--zcvt7epac0t1ebcsjfyf7cwz25", "--")); + assertFalse(Uxid.isDeterministic("usr--01hf7yat0004h1", "--")); + assertFalse(Uxid.isDeterministic("usr--zcvt7epac0t1ebcsjfyf7cwz25", "_")); + assertThrows( + IllegalArgumentException.class, + () -> Uxid.isDeterministic("usr_zcvt7epac0t1ebcsjfyf7cwz25", "")); + assertThrows(NullPointerException.class, () -> Uxid.isDeterministic(null)); + } +}