One-time, mechanical reformat — no functional changes. The tree was not rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock `cargo fmt` defaults so a `cargo fmt --check` CI gate can follow. Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline), clippy clean. A .git-blame-ignore-revs entry follows so `git blame` skips this commit.
119 lines
3.6 KiB
Rust
119 lines
3.6 KiB
Rust
//! Shortid generation and validation.
|
||
//!
|
||
//! Per ADR-0005, the `shortid` user-facing type is a base58
|
||
//! random identifier of 10–12 characters with no ambiguous
|
||
//! glyphs (no `0`/`O`/`I`/`l`). The generator is small enough
|
||
//! to live in the DSL crate alongside the type definition.
|
||
|
||
use rand::RngExt;
|
||
|
||
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
|
||
/// because they are easily confused in print.
|
||
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||
|
||
const DEFAULT_LEN: usize = 10;
|
||
|
||
/// Length bounds accepted on user-supplied shortid values.
|
||
pub const MIN_LEN: usize = 10;
|
||
pub const MAX_LEN: usize = 12;
|
||
|
||
/// Generate a fresh shortid using the thread-local RNG.
|
||
#[must_use]
|
||
pub fn generate() -> String {
|
||
generate_with_rng(&mut rand::rng())
|
||
}
|
||
|
||
/// Generate a shortid from a caller-supplied RNG.
|
||
///
|
||
/// Lets `seed --seed <n>` produce **reproducible** shortid values
|
||
/// (ADR-0048 D4) by threading its seeded RNG through, while the default
|
||
/// [`generate`] keeps its thread-RNG behaviour for ordinary inserts.
|
||
#[must_use]
|
||
pub fn generate_with_rng<R: RngExt + ?Sized>(rng: &mut R) -> String {
|
||
let mut out = String::with_capacity(DEFAULT_LEN);
|
||
for _ in 0..DEFAULT_LEN {
|
||
let idx = rng.random_range(0..ALPHABET.len());
|
||
out.push(ALPHABET[idx] as char);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Validate a user-supplied shortid value.
|
||
pub fn validate(value: &str) -> Result<(), String> {
|
||
if value.len() < MIN_LEN || value.len() > MAX_LEN {
|
||
return Err(format!(
|
||
"shortid must be {MIN_LEN}–{MAX_LEN} characters; got {} character(s)",
|
||
value.chars().count()
|
||
));
|
||
}
|
||
for c in value.chars() {
|
||
if !ALPHABET.contains(&(c as u8)) {
|
||
return Err(format!(
|
||
"shortid contains '{c}', which is not in the base58 alphabet \
|
||
(no 0, O, I, or l; ASCII letters and digits otherwise)"
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use pretty_assertions::assert_eq;
|
||
|
||
#[test]
|
||
fn generated_ids_have_default_length() {
|
||
let id = generate();
|
||
assert_eq!(id.len(), DEFAULT_LEN);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_ids_use_only_base58_alphabet() {
|
||
for _ in 0..100 {
|
||
let id = generate();
|
||
for c in id.chars() {
|
||
assert!(
|
||
ALPHABET.contains(&(c as u8)),
|
||
"char {c:?} not in base58 alphabet"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn generated_ids_are_not_all_identical() {
|
||
// Probabilistically extremely unlikely with a good RNG;
|
||
// catches a wholly broken generator (constant output).
|
||
let a = generate();
|
||
let b = generate();
|
||
let c = generate();
|
||
assert!(
|
||
a != b || b != c,
|
||
"all three generated ids were identical: {a}, {b}, {c}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_accepts_well_formed_values() {
|
||
assert!(validate("23456789Ab").is_ok()); // 10 chars
|
||
assert!(validate("23456789AbCD").is_ok()); // 12 chars
|
||
}
|
||
|
||
#[test]
|
||
fn validate_rejects_too_short_or_too_long() {
|
||
let err = validate("short").unwrap_err();
|
||
assert!(err.contains("characters"));
|
||
let err = validate("waytoolongafornow").unwrap_err();
|
||
assert!(err.contains("characters"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_rejects_ambiguous_glyphs() {
|
||
for bad in ["0aaaaaaaaa", "Oaaaaaaaaa", "Iaaaaaaaaa", "laaaaaaaaa"] {
|
||
let err = validate(bad).unwrap_err();
|
||
assert!(err.contains("base58"), "for {bad}: {err}");
|
||
}
|
||
}
|
||
}
|