|
package io.fouad; |
|
|
|
import java.math.BigInteger; |
|
import java.security.SecureRandom; |
|
import java.util.BitSet; |
|
import java.util.Random; |
|
|
|
/** |
|
* Obfuscates sequential ids into seemingly-random numbers that land inside a chosen |
|
* inclusive output range {@code [lowerLimit, upperLimit]}, using a multiplicative-inverse |
|
* (modular multiplication) mapping. |
|
* |
|
* <p>Reference: <a href="https://ericlippert.com/2013/11/14/a-practical-use-of-multiplicative-inverses/"> |
|
* Eric Lippert — A practical use of multiplicative inverses</a>. |
|
* |
|
* <p>Let {@code count = upperLimit - lowerLimit + 1} be the total number of sequences. The |
|
* input is always {@code 1..count}; the limits bound only the output. For an input |
|
* {@code sequence} in {@code [1, count]} the mapping is: |
|
* <pre> |
|
* offset = sequence - 1 // 0-based position, in [0, count) |
|
* obfuscated = lowerLimit + (offset * multiplier) mod count |
|
* </pre> |
|
* where {@code multiplier} is any value coprime with {@code count}. |
|
* |
|
* <p>Because {@code gcd(multiplier, count) == 1}, the mapping is a bijection from the input |
|
* domain {@code [1, count]} onto the output range {@code [lowerLimit, upperLimit]}: every |
|
* input maps to a distinct output inside the range, with no collisions, and it can be |
|
* reversed via the modular inverse. |
|
* |
|
* <p>Examples: |
|
* <ul> |
|
* <li>{@code forDigits(8)} → input {@code 1..100_000_000}, output {@code [0, 99_999_999]}.</li> |
|
* <li>{@code forRange(1_000_000_000L, 1_999_999_999L)} → input {@code 1..1_000_000_000}, |
|
* output 10-digit ids that all start with 1.</li> |
|
* </ul> |
|
*/ |
|
public final class MappedSequenceGenerator { |
|
|
|
/** |
|
* Smallest value the obfuscated output can take, inclusive. |
|
*/ |
|
private final long lowerLimit; |
|
|
|
/** |
|
* Largest value the obfuscated output can take, inclusive. |
|
*/ |
|
private final long upperLimit; |
|
|
|
/** |
|
* Total number of sequences, equal to {@code upperLimit - lowerLimit + 1}. |
|
*/ |
|
private final long count; |
|
|
|
/** |
|
* Multiplier coprime with {@link #count}, which guarantees a collision-free bijection. |
|
*/ |
|
private final long multiplier; |
|
|
|
/** |
|
* Modular inverse of {@link #multiplier} modulo {@link #count}, used to reverse the mapping. |
|
*/ |
|
private final long inverse; |
|
|
|
/** |
|
* @param lowerLimit smallest value in the output range, inclusive (must be {@code >= 0}) |
|
* @param upperLimit largest value in the output range, inclusive (must be {@code > lowerLimit}) |
|
* @param multiplier value coprime with {@code count = upperLimit - lowerLimit + 1} |
|
* @throws IllegalArgumentException if the limits are invalid or the multiplier is not coprime |
|
*/ |
|
public MappedSequenceGenerator(long lowerLimit, long upperLimit, long multiplier) { |
|
if (lowerLimit < 0) { |
|
throw new IllegalArgumentException("lowerLimit must be >= 0, was " + lowerLimit); |
|
} |
|
if (upperLimit <= lowerLimit) { |
|
throw new IllegalArgumentException( |
|
"upperLimit (" + upperLimit + ") must be greater than lowerLimit (" + lowerLimit + ")"); |
|
} |
|
long size = upperLimit - lowerLimit + 1; |
|
if (size <= 0) { // overflowed: the span exceeds Long.MAX_VALUE |
|
throw new IllegalArgumentException( |
|
"range [" + lowerLimit + ", " + upperLimit + "] is too large to represent"); |
|
} |
|
long normalizedMultiplier = Math.floorMod(multiplier, size); |
|
if (gcd(normalizedMultiplier, size) != 1) { |
|
throw new IllegalArgumentException( |
|
"multiplier " + multiplier + " is not coprime with count " + size + " (gcd must be 1)"); |
|
} |
|
this.lowerLimit = lowerLimit; |
|
this.upperLimit = upperLimit; |
|
this.count = size; |
|
this.multiplier = normalizedMultiplier; |
|
this.inverse = BigInteger.valueOf(normalizedMultiplier) |
|
.modInverse(BigInteger.valueOf(size)) |
|
.longValueExact(); |
|
} |
|
|
|
/** |
|
* Builds a generator over the default 0-based output space for ids of the given width, |
|
* i.e. range {@code [0, 10^numberOfDigits - 1]}, picking a random coprime multiplier. |
|
* |
|
* @param numberOfDigits number of decimal digits (1..18) |
|
*/ |
|
public static MappedSequenceGenerator forDigits(int numberOfDigits) { |
|
return forDigits(numberOfDigits, new SecureRandom()); |
|
} |
|
|
|
/** |
|
* Same as {@link #forDigits(int)} but with a caller-supplied source of randomness |
|
* (useful for reproducible tests). |
|
*/ |
|
public static MappedSequenceGenerator forDigits(int numberOfDigits, Random random) { |
|
if (numberOfDigits < 1 || numberOfDigits > 18) { |
|
// 10^19 overflows a long, so cap at 18 digits. |
|
throw new IllegalArgumentException("numberOfDigits must be in 1..18, was " + numberOfDigits); |
|
} |
|
return forRange(0, pow10(numberOfDigits) - 1, random); |
|
} |
|
|
|
/** |
|
* Builds a generator over the inclusive output range {@code [lowerLimit, upperLimit]}, |
|
* picking a random multiplier coprime with the range size. |
|
*/ |
|
public static MappedSequenceGenerator forRange(long lowerLimit, long upperLimit) { |
|
return forRange(lowerLimit, upperLimit, new SecureRandom()); |
|
} |
|
|
|
/** |
|
* Same as {@link #forRange(long, long)} but with a caller-supplied source of randomness. |
|
* |
|
* <p>Passing a seeded {@link Random} (e.g. {@code new Random(42)}) makes the chosen |
|
* multiplier reproducible: the same {@code (lowerLimit, upperLimit, seed)} rebuilds the |
|
* same generator on later runs, so only the seed need be stored. The no-argument |
|
* {@link #forDigits(int)} and {@link #forRange(long, long)} factories use a |
|
* {@link SecureRandom}, which is not reproducible. |
|
*/ |
|
public static MappedSequenceGenerator forRange(long lowerLimit, long upperLimit, Random random) { |
|
if (upperLimit <= lowerLimit) { |
|
throw new IllegalArgumentException( |
|
"upperLimit (" + upperLimit + ") must be greater than lowerLimit (" + lowerLimit + ")"); |
|
} |
|
long size = upperLimit - lowerLimit + 1; |
|
if (size <= 0) { |
|
throw new IllegalArgumentException( |
|
"range [" + lowerLimit + ", " + upperLimit + "] is too large to represent"); |
|
} |
|
long multiplier = randomCoprimeMultiplier(size, random); |
|
return new MappedSequenceGenerator(lowerLimit, upperLimit, multiplier); |
|
} |
|
|
|
/** |
|
* Reconstructs a generator from previously-saved keys so it reproduces the exact same |
|
* mapping on later runs. Store {@link #getLowerLimit()}, {@link #getUpperLimit()} and |
|
* {@link #getMultiplier()}, then pass them back here. |
|
* |
|
* <p>Behaves exactly like {@link #MappedSequenceGenerator(long, long, long)}; it exists |
|
* to make the intent of rebuilding from saved keys explicit at the call site. |
|
*/ |
|
public static MappedSequenceGenerator fromKeys(long lowerLimit, long upperLimit, long multiplier) { |
|
return new MappedSequenceGenerator(lowerLimit, upperLimit, multiplier); |
|
} |
|
|
|
/** |
|
* Parses the {@code "lowerLimit:upperLimit:multiplier"} token produced by |
|
* {@link #serialize()} and rebuilds the corresponding generator. |
|
* |
|
* @throws IllegalArgumentException if the token is malformed or the keys are invalid |
|
*/ |
|
public static MappedSequenceGenerator deserialize(String token) { |
|
if (token == null) { |
|
throw new IllegalArgumentException("token must not be null"); |
|
} |
|
String[] parts = token.trim().split(":"); |
|
if (parts.length != 3) { |
|
throw new IllegalArgumentException( |
|
"expected \"lowerLimit:upperLimit:multiplier\", was \"" + token + "\""); |
|
} |
|
try { |
|
return new MappedSequenceGenerator( |
|
Long.parseLong(parts[0].trim()), |
|
Long.parseLong(parts[1].trim()), |
|
Long.parseLong(parts[2].trim())); |
|
} catch (NumberFormatException e) { |
|
throw new IllegalArgumentException("token contains a non-numeric field: \"" + token + "\"", e); |
|
} |
|
} |
|
|
|
/** |
|
* Obfuscates a sequence number into an id inside the output range {@code [lowerLimit, upperLimit]}. |
|
* |
|
* @param sequence original sequence number, must be within {@code [1, count]} |
|
*/ |
|
public long obfuscate(long sequence) { |
|
if (sequence < 1 || sequence > count) { |
|
throw new IllegalArgumentException( |
|
"sequence must be in [1, " + count + "], was " + sequence); |
|
} |
|
long offset = sequence - 1; |
|
return lowerLimit + modMultiply(offset, multiplier, count); |
|
} |
|
|
|
/** |
|
* Reverses {@link #obfuscate(long)}, recovering the original sequence number in {@code [1, count]}. |
|
*/ |
|
public long deobfuscate(long obfuscated) { |
|
if (obfuscated < lowerLimit || obfuscated > upperLimit) { |
|
throw new IllegalArgumentException( |
|
"value must be in [" + lowerLimit + ", " + upperLimit + "], was " + obfuscated); |
|
} |
|
long offset = obfuscated - lowerLimit; |
|
return modMultiply(offset, inverse, count) + 1; |
|
} |
|
|
|
/** |
|
* Exhaustively verifies that obfuscating every sequence number in {@code [1, count]} |
|
* yields only in-range outputs and no duplicates, confirming the mapping is a bijection |
|
* onto {@code [lowerLimit, upperLimit]}. Since {@code lowerLimit >= 0}, an in-range output |
|
* is also guaranteed to be non-negative. |
|
* |
|
* <p>This is an O(count) scan and is only feasible while the whole range fits a |
|
* {@link BitSet}, whose index is an int, i.e. {@code count <= Integer.MAX_VALUE}. For |
|
* larger ranges the coprimality enforced in the constructor already guarantees the |
|
* bijection, so an exhaustive scan is unnecessary. |
|
* |
|
* @return {@code true} if every output is within the range and unique |
|
* @throws IllegalStateException if the range is too large to scan exhaustively |
|
*/ |
|
public boolean verifyNoDuplicates() { |
|
if (count > Integer.MAX_VALUE) { |
|
throw new IllegalStateException( |
|
"count " + count + " is too large to brute-force verify; " |
|
+ "coprimality already guarantees a bijection"); |
|
} |
|
BitSet seen = new BitSet((int) count); |
|
for (long sequence = 1; sequence <= count; sequence++) { |
|
long out = obfuscate(sequence); |
|
if (out < lowerLimit || out > upperLimit) { |
|
// Out of range, which (since lowerLimit >= 0) also catches any negative value. |
|
return false; |
|
} |
|
int index = (int) (out - lowerLimit); |
|
if (seen.get(index)) { |
|
return false; // collision found |
|
} |
|
seen.set(index); |
|
} |
|
// Every input produced a distinct in-range output, so full coverage is confirmed too. |
|
return seen.cardinality() == count; |
|
} |
|
|
|
/** |
|
* Returns the smallest value the obfuscated output can take, inclusive. |
|
*/ |
|
public long getLowerLimit() { |
|
return lowerLimit; |
|
} |
|
|
|
/** |
|
* Returns the largest value the obfuscated output can take, inclusive. |
|
*/ |
|
public long getUpperLimit() { |
|
return upperLimit; |
|
} |
|
|
|
/** |
|
* Returns the total number of sequences, equal to {@code upperLimit - lowerLimit + 1}. |
|
*/ |
|
public long getCount() { |
|
return count; |
|
} |
|
|
|
/** |
|
* Returns the multiplier used by the mapping, which is coprime with {@link #getCount()}. |
|
*/ |
|
public long getMultiplier() { |
|
return multiplier; |
|
} |
|
|
|
/** |
|
* Returns a compact token holding everything needed to rebuild this exact generator, |
|
* formatted as {@code "lowerLimit:upperLimit:multiplier"}. Store it and reload with |
|
* {@link #deserialize(String)}. |
|
*/ |
|
public String serialize() { |
|
return lowerLimit + ":" + upperLimit + ":" + multiplier; |
|
} |
|
|
|
/** |
|
* Returns a human-readable description of the output range, count and multiplier. |
|
*/ |
|
@Override |
|
public String toString() { |
|
return "MappedSequenceGenerator{range=[" + lowerLimit + ", " + upperLimit + "]" |
|
+ ", count=" + count + ", multiplier=" + multiplier + '}'; |
|
} |
|
|
|
// --- helpers ----------------------------------------------------------------- |
|
|
|
/** |
|
* Computes {@code (a * b) mod modulus} without overflowing, falling back to |
|
* {@link BigInteger} for large moduli. |
|
*/ |
|
private static long modMultiply(long a, long b, long modulus) { |
|
// a and b are both < modulus. If modulus^2 fits in a long (modulus <= ~3.03e9), |
|
// the plain product is exact. |
|
if (modulus <= 3_037_000_499L) { |
|
return (a * b) % modulus; |
|
} |
|
return BigInteger.valueOf(a) |
|
.multiply(BigInteger.valueOf(b)) |
|
.mod(BigInteger.valueOf(modulus)) |
|
.longValueExact(); |
|
} |
|
|
|
/** |
|
* Picks a random multiplier coprime with {@code count}, avoiding the identity (1) when possible. |
|
*/ |
|
private static long randomCoprimeMultiplier(long count, Random random) { |
|
if (count <= 2) { |
|
return 1; // too few elements for a non-trivial multiplier |
|
} |
|
while (true) { |
|
// Uniform in [1, count - 1]. |
|
long candidate = 1 + Math.floorMod(random.nextLong(), count - 1); |
|
// Skip 1: it would be the identity map (no obfuscation at all). |
|
if (candidate != 1 && gcd(candidate, count) == 1) { |
|
return candidate; |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Returns the greatest common divisor of {@code a} and {@code b} using the Euclidean algorithm. |
|
*/ |
|
private static long gcd(long a, long b) { |
|
while (b != 0) { |
|
long t = b; |
|
b = a % b; |
|
a = t; |
|
} |
|
return a; |
|
} |
|
|
|
/** |
|
* Returns {@code 10^n} as a long. The caller must ensure {@code n <= 18} to avoid overflow. |
|
*/ |
|
private static long pow10(int n) { |
|
long result = 1; |
|
for (int i = 0; i < n; i++) { |
|
result *= 10; |
|
} |
|
return result; |
|
} |
|
} |