From 56ebcaf50d0dd705d49f0e9355d60c171f412178 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sat, 22 Oct 2022 20:17:06 -0700 Subject: [PATCH] Add `Username` type (#132) There are a number of places where usernames are passed around. Using this type ensures that the contained string is actually a valid username and not some other kind of string. For instance you can use it as a function argument to indicate that only valid usernames are accepted, or return it from a function to indicate that only valid usernames are produced. This is analogous to the existing `Ident` type. --- src/client.rs | 7 +- src/config.rs | 3 +- src/ident.rs | 5 +- src/lib.rs | 2 + src/protocol/packets.rs | 1 + src/protocol/packets/c2s.rs | 2 +- src/protocol/packets/s2c.rs | 2 +- src/server.rs | 12 +-- src/server/login.rs | 29 ++++-- src/username.rs | 185 ++++++++++++++++++++++++++++++++++++ src/util.rs | 22 ----- 11 files changed, 224 insertions(+), 46 deletions(-) create mode 100644 src/username.rs diff --git a/src/client.rs b/src/client.rs index fcca223..a9a3e0a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -45,6 +45,7 @@ use crate::protocol::{BoundedInt, BoundedString, ByteAngle, RawBytes, SlotId, Va use crate::server::{C2sPacketChannels, NewClientData, S2cPlayMessage, SharedServer}; use crate::slab_versioned::{Key, VersionedSlab}; use crate::text::Text; +use crate::username::Username; use crate::util::{chunks_in_view_distance, is_chunk_in_view_distance}; use crate::world::{WorldId, Worlds}; use crate::{ident, LIBRARY_NAMESPACE}; @@ -191,7 +192,7 @@ pub struct Client { send: SendOpt, recv: Receiver, uuid: Uuid, - username: String, + username: Username, textures: Option, /// World client is currently in. Default value is **invalid** and must /// be set by calling [`Client::spawn`]. @@ -331,8 +332,8 @@ impl Client { } /// Gets the username of this client. - pub fn username(&self) -> &str { - &self.username + pub fn username(&self) -> Username<&str> { + self.username.as_str_username() } /// Gets the player textures of this client. If the client does not have diff --git a/src/config.rs b/src/config.rs index 2ace070..4dcbdbe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ use crate::biome::Biome; use crate::dimension::Dimension; use crate::server::{NewClientData, Server, SharedServer}; use crate::text::Text; +use crate::username::Username; use crate::{Ticks, STANDARD_TPS}; /// A trait for the configuration of a server. @@ -232,7 +233,7 @@ pub trait Config: Sized + Send + Sync + 'static { fn format_session_server_url( &self, server: &SharedServer, - username: &str, + username: Username<&str>, auth_digest: &str, player_ip: &IpAddr, ) -> String { diff --git a/src/ident.rs b/src/ident.rs index 077bdfa..7be0d4b 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -151,7 +151,10 @@ where } } -impl> fmt::Display for Ident { +impl fmt::Display for Ident +where + S: AsRef, +{ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{}:{}", self.namespace(), self.path()) } diff --git a/src/lib.rs b/src/lib.rs index 8b99a13..c6a1429 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -123,6 +123,7 @@ mod slab_rc; mod slab_versioned; pub mod spatial_index; pub mod text; +pub mod username; pub mod util; pub mod world; @@ -145,6 +146,7 @@ pub mod prelude { pub use server::{NewClientData, Server, SharedServer, ShutdownResult}; pub use spatial_index::{RaycastHit, SpatialIndex}; pub use text::{Color, Text, TextFormat}; + pub use username::Username; pub use util::{ chunks_in_view_distance, from_yaw_and_pitch, is_chunk_in_view_distance, to_yaw_and_pitch, }; diff --git a/src/protocol/packets.rs b/src/protocol/packets.rs index 4aaa889..7db89fe 100644 --- a/src/protocol/packets.rs +++ b/src/protocol/packets.rs @@ -23,6 +23,7 @@ use crate::protocol::{ VarLong, }; use crate::text::Text; +use crate::username::Username; /// Provides the name of a packet for debugging purposes. pub trait PacketName { diff --git a/src/protocol/packets/c2s.rs b/src/protocol/packets/c2s.rs index 79a9c9d..786f677 100644 --- a/src/protocol/packets/c2s.rs +++ b/src/protocol/packets/c2s.rs @@ -56,7 +56,7 @@ pub mod login { def_struct! { LoginStart { - username: BoundedString<3, 16>, + username: Username, sig_data: Option, profile_id: Option, } diff --git a/src/protocol/packets/s2c.rs b/src/protocol/packets/s2c.rs index e670774..82d6dc6 100644 --- a/src/protocol/packets/s2c.rs +++ b/src/protocol/packets/s2c.rs @@ -48,7 +48,7 @@ pub mod login { def_struct! { LoginSuccess { uuid: Uuid, - username: BoundedString<3, 16>, + username: Username, properties: Vec, } } diff --git a/src/server.rs b/src/server.rs index 3234e53..1ad5fa7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -38,8 +38,8 @@ use crate::protocol::packets::c2s::status::{PingRequest, StatusRequest}; use crate::protocol::packets::s2c::login::{DisconnectLogin, LoginSuccess, SetCompression}; use crate::protocol::packets::s2c::play::S2cPlayPacket; use crate::protocol::packets::s2c::status::{PingResponse, StatusResponse}; -use crate::protocol::{BoundedString, VarInt}; -use crate::util::valid_username; +use crate::protocol::VarInt; +use crate::username::Username; use crate::world::Worlds; use crate::{ident, Ticks, PROTOCOL_VERSION, VERSION_NAME}; @@ -120,7 +120,7 @@ pub struct NewClientData { /// The UUID of the new client. pub uuid: Uuid, /// The username of the new client. - pub username: String, + pub username: Username, /// The new client's player textures. May be `None` if the client does not /// have a skin or cape. pub textures: Option, @@ -627,13 +627,11 @@ async fn handle_login( } let LoginStart { - username: BoundedString(username), + username, sig_data: _, // TODO profile_id: _, // TODO } = c.dec.read_packet().await?; - ensure!(valid_username(&username), "invalid username '{username}'"); - let ncd = match server.connection_mode() { ConnectionMode::Online => login::online(server, c, remote_addr, username).await?, ConnectionMode::Offline => login::offline(remote_addr, username)?, @@ -660,7 +658,7 @@ async fn handle_login( c.enc .write_packet(&LoginSuccess { uuid: ncd.uuid, - username: ncd.username.clone().into(), + username: ncd.username.clone(), properties: Vec::new(), }) .await?; diff --git a/src/server/login.rs b/src/server/login.rs index 943d6b4..5a36c3b 100644 --- a/src/server/login.rs +++ b/src/server/login.rs @@ -25,9 +25,10 @@ use crate::protocol::packets::s2c::login::{ DisconnectLogin, EncryptionRequest, LoginPluginRequest, }; use crate::protocol::packets::Property; -use crate::protocol::{BoundedArray, BoundedString, Decode, RawBytes, VarInt}; +use crate::protocol::{BoundedArray, Decode, RawBytes, VarInt}; use crate::server::{Codec, NewClientData, SharedServer}; use crate::text::Text; +use crate::username::Username; /// Login sequence for /// [`ConnectionMode::Online`](crate::config::ConnectionMode). @@ -35,7 +36,7 @@ pub(super) async fn online( server: &SharedServer, c: &mut Codec, remote_addr: SocketAddr, - username: String, + username: Username, ) -> anyhow::Result { let my_verify_token: [u8; 16] = rand::random(); @@ -86,7 +87,7 @@ pub(super) async fn online( #[derive(Debug, Deserialize)] struct AuthResponse { id: String, - name: String, + name: Username, properties: Vec, } @@ -97,7 +98,7 @@ pub(super) async fn online( let url = server.config().format_session_server_url( server, - &username, + username.as_str_username(), &auth_digest(&hash), &remote_addr.ip(), ); @@ -140,10 +141,13 @@ pub(super) async fn online( /// Login sequence for /// [`ConnectionMode::Offline`](crate::config::ConnectionMode). -pub(super) fn offline(remote_addr: SocketAddr, username: String) -> anyhow::Result { +pub(super) fn offline( + remote_addr: SocketAddr, + username: Username, +) -> anyhow::Result { Ok(NewClientData { // Derive the client's UUID from a hash of their username. - uuid: Uuid::from_slice(&Sha256::digest(&username)[..16])?, + uuid: Uuid::from_slice(&Sha256::digest(username.as_str())[..16])?, username, textures: None, remote_addr: remote_addr.ip(), @@ -152,7 +156,10 @@ pub(super) fn offline(remote_addr: SocketAddr, username: String) -> anyhow::Resu /// Login sequence for /// [`ConnectionMode::BungeeCord`](crate::config::ConnectionMode). -pub(super) fn bungeecord(server_address: &str, username: String) -> anyhow::Result { +pub(super) fn bungeecord( + server_address: &str, + username: Username, +) -> anyhow::Result { // Get data from server_address field of the handshake let [_, client_ip, uuid, properties]: [&str; 4] = server_address .split('\0') @@ -194,7 +201,7 @@ fn auth_digest(bytes: &[u8]) -> String { pub(super) async fn velocity( c: &mut Codec, - username: String, + username: Username, velocity_secret: &str, ) -> anyhow::Result { const VELOCITY_MIN_SUPPORTED_VERSION: u8 = 1; @@ -245,8 +252,10 @@ pub(super) async fn velocity( let uuid = Uuid::decode(&mut data_without_signature)?; // Get username and validate - let velocity_username = BoundedString::<0, 16>::decode(&mut data_without_signature)?.0; - ensure!(username == velocity_username, "mismatched usernames"); + ensure!( + username == Username::decode(&mut data_without_signature)?, + "mismatched usernames" + ); // Read properties and get textures let mut textures = None; diff --git a/src/username.rs b/src/username.rs new file mode 100644 index 0000000..3a0135e --- /dev/null +++ b/src/username.rs @@ -0,0 +1,185 @@ +use std::borrow::Borrow; +use std::error::Error; +use std::fmt; +use std::fmt::Formatter; +use std::io::Write; +use std::str::FromStr; + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::protocol::{Decode, Encode}; + +/// A newtype wrapper around a string type `S` which guarantees the wrapped +/// string meets the criteria for a valid Minecraft username. +/// +/// A valid username is 3 to 16 characters long with only ASCII alphanumeric +/// characters. The username must match the regex `^[a-zA-Z0-9_]{3,16}$` to be +/// considered valid. +/// +/// # Contract +/// +/// The type `S` must meet the following criteria: +/// - All calls to [`AsRef::as_ref`] and [`Borrow::borrow`] while the string is +/// wrapped in `Username` must return the same value. +/// +/// # Examples +/// +/// ``` +/// use valence::prelude::*; +/// +/// assert!(Username::new("00a").is_ok()); +/// assert!(Username::new("jeb_").is_ok()); +/// +/// assert!(Username::new("notavalidusername").is_err()); +/// assert!(Username::new("NotValid!").is_err()); +/// ``` +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize)] +#[repr(transparent)] +#[serde(transparent)] +pub struct Username(S); + +impl> Username { + pub fn new(string: S) -> Result> { + let s = string.as_ref(); + + if (3..=16).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + Ok(Self(string)) + } else { + Err(UsernameError(string)) + } + } + + pub fn as_str(&self) -> &str { + self.0.as_ref() + } + + pub fn as_str_username(&self) -> Username<&str> { + Username(self.0.as_ref()) + } + + pub fn to_owned_username(&self) -> Username + where + S: ToOwned, + S::Owned: AsRef, + { + Username(self.0.to_owned()) + } + + pub fn into_inner(self) -> S { + self.0 + } +} + +impl AsRef for Username +where + S: AsRef, +{ + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl Borrow for Username +where + S: Borrow, +{ + fn borrow(&self) -> &str { + self.0.borrow() + } +} + +impl FromStr for Username { + type Err = UsernameError; + + fn from_str(s: &str) -> Result { + Username::new(s.to_owned()) + } +} + +impl TryFrom for Username { + type Error = UsernameError; + + fn try_from(value: String) -> Result { + Username::new(value) + } +} + +impl From> for String +where + S: Into + AsRef, +{ + fn from(value: Username) -> Self { + value.0.into() + } +} + +impl fmt::Display for Username +where + S: AsRef, +{ + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + self.0.as_ref().fmt(f) + } +} + +impl Encode for Username +where + S: Encode, +{ + fn encode(&self, w: &mut impl Write) -> anyhow::Result<()> { + self.0.encode(w) + } + + fn encoded_len(&self) -> usize { + self.0.encoded_len() + } +} + +impl Decode for Username +where + S: Decode + AsRef + Send + Sync + 'static, +{ + fn decode(r: &mut &[u8]) -> anyhow::Result { + Ok(Username::new(S::decode(r)?)?) + } +} + +impl<'de, S> Deserialize<'de> for Username +where + S: Deserialize<'de> + AsRef, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Username::new(S::deserialize(deserializer)?).map_err(D::Error::custom) + } +} + +/// The error type created when a [`Username`] cannot be parsed from a string. +/// Contains the offending string. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UsernameError(pub S); + +impl fmt::Debug for UsernameError +where + S: AsRef, +{ + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.debug_tuple("UsernameError") + .field(&self.0.as_ref()) + .finish() + } +} + +impl fmt::Display for UsernameError +where + S: AsRef, +{ + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "invalid username \"{}\"", self.0.as_ref()) + } +} + +impl Error for UsernameError where S: AsRef {} diff --git a/src/util.rs b/src/util.rs index 99b7f4e..edb6f12 100644 --- a/src/util.rs +++ b/src/util.rs @@ -8,28 +8,6 @@ use vek::{Aabb, Vec3}; use crate::chunk_pos::ChunkPos; -/// Returns true if the given string meets the criteria for a valid Minecraft -/// username. -/// -/// Usernames are valid if they match the regex `^[a-zA-Z0-9_]{3,16}$`. -/// -/// # Examples -/// -/// ``` -/// use valence::util::valid_username; -/// -/// assert!(valid_username("00a")); -/// assert!(valid_username("jeb_")); -/// -/// assert!(!valid_username("notavalidusername")); -/// assert!(!valid_username("NotValid!")); -/// ``` -pub fn valid_username(s: &str) -> bool { - (3..=16).contains(&s.len()) - && s.chars() - .all(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_')) -} - const EXTRA_RADIUS: i32 = 3; /// Returns an iterator over all chunk positions within a view distance,