use md5::{Digest, Md5};
use thiserror::Error;
use uuid::Uuid;
pub const MAX_USERNAME_LEN: usize = 16;
pub fn offline_uuid(name: &str) -> Uuid {
let mut hasher = Md5::new();
hasher.update(b"OfflinePlayer:");
hasher.update(name.as_bytes());
let mut bytes: [u8; 16] = hasher.finalize().into();
bytes[6] = (bytes[6] & 0x0f) | 0x30;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Uuid::from_bytes(bytes)
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum UsernameError {
#[error("username is empty")]
Empty,
#[error("username is {len} characters long, the maximum is {MAX_USERNAME_LEN}")]
TooLong {
len: usize,
},
#[error("username contains the invalid character {0:?}")]
InvalidChar(char),
}
pub fn validate_username(name: &str) -> Result<(), UsernameError> {
check_len(name)?;
match name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '_'))
{
Some(c) => Err(UsernameError::InvalidChar(c)),
None => Ok(()),
}
}
pub fn validate_username_lenient(name: &str) -> Result<(), UsernameError> {
check_len(name)?;
match name.chars().find(|c| c.is_control()) {
Some(c) => Err(UsernameError::InvalidChar(c)),
None => Ok(()),
}
}
fn check_len(name: &str) -> Result<(), UsernameError> {
if name.is_empty() {
return Err(UsernameError::Empty);
}
let len = name.encode_utf16().count();
if len > MAX_USERNAME_LEN {
return Err(UsernameError::TooLong { len });
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OfflineProfile {
name: String,
uuid: Uuid,
}
impl OfflineProfile {
pub fn new(name: impl Into<String>) -> Result<Self, UsernameError> {
let name = name.into();
validate_username(&name)?;
Ok(Self::from_valid(name))
}
pub fn new_lenient(name: impl Into<String>) -> Result<Self, UsernameError> {
let name = name.into();
validate_username_lenient(&name)?;
Ok(Self::from_valid(name))
}
fn from_valid(name: String) -> Self {
let uuid = offline_uuid(&name);
Self { name, uuid }
}
pub fn name(&self) -> &str {
&self.name
}
pub fn uuid(&self) -> Uuid {
self.uuid
}
}
#[cfg(test)]
mod tests {
use super::*;
const KAT: &[(&str, &str)] = &[
("Notch", "b50ad385-829d-3141-a216-7e7d7539ba7f"),
("jeb_", "a762f560-4fce-3236-812a-b80efff0b62b"),
("Crefax", "0b5961bd-6ee4-399a-9dd0-f0ea1ff523d0"),
("a", "52428a0e-1e30-3cb1-976c-e728b2614047"),
(
"\u{c4}\u{d6}\u{fc}_x",
"d31745e6-b554-3a81-bbc7-cea01c69cc7a",
),
];
#[test]
fn offline_uuid_matches_java() {
for (name, expected) in KAT {
assert_eq!(offline_uuid(name).to_string(), *expected, "name {name:?}");
}
}
#[test]
fn offline_uuid_is_version_3() {
let u = offline_uuid("Notch");
assert_eq!(u.get_version_num(), 3);
assert_eq!(u.get_variant(), uuid::Variant::RFC4122);
}
#[test]
fn strict_validation() {
assert!(validate_username("Steve").is_ok());
assert!(validate_username("a").is_ok());
assert!(validate_username("abcdefghijklmnop").is_ok());
assert!(validate_username("Bot_01").is_ok());
assert_eq!(validate_username(""), Err(UsernameError::Empty));
assert_eq!(
validate_username("abcdefghijklmnopq"),
Err(UsernameError::TooLong { len: 17 })
);
assert_eq!(
validate_username("a b"),
Err(UsernameError::InvalidChar(' '))
);
assert_eq!(
validate_username("caf\u{e9}"),
Err(UsernameError::InvalidChar('\u{e9}'))
);
assert_eq!(
validate_username("a-b"),
Err(UsernameError::InvalidChar('-'))
);
}
#[test]
fn lenient_validation() {
assert!(validate_username_lenient("caf\u{e9}-1").is_ok());
assert_eq!(
validate_username_lenient("a\nb"),
Err(UsernameError::InvalidChar('\n'))
);
assert!(validate_username_lenient(&"\u{e9}".repeat(16)).is_ok());
assert_eq!(
validate_username_lenient(&"\u{1f600}".repeat(9)),
Err(UsernameError::TooLong { len: 18 })
);
}
#[test]
fn profile() {
let p = OfflineProfile::new("Notch").unwrap();
assert_eq!(p.name(), "Notch");
assert_eq!(p.uuid(), offline_uuid("Notch"));
assert!(OfflineProfile::new("no spaces").is_err());
assert!(OfflineProfile::new_lenient("caf\u{e9}").is_ok());
}
}