diff --git a/ruma-identifiers/.builds/beta.yml b/ruma-identifiers/.builds/beta.yml new file mode 100644 index 00000000..ad2e33a7 --- /dev/null +++ b/ruma-identifiers/.builds/beta.yml @@ -0,0 +1,30 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-identifiers +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install beta --profile minimal -c rustfmt -c clippy + rustup default beta + - test: | + cd ruma-identifiers + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --no-default-features --verbose + test1_exit=$? + + cargo test --all-features --verbose + test2_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test1_exit || $test2_exit )) diff --git a/ruma-identifiers/.builds/msrv.yml b/ruma-identifiers/.builds/msrv.yml new file mode 100644 index 00000000..4e7eb631 --- /dev/null +++ b/ruma-identifiers/.builds/msrv.yml @@ -0,0 +1,26 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-identifiers +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install 1.42.0 --profile minimal + rustup default 1.42.0 + - test: | + cd ruma-identifiers + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + # Only make sure the code builds with the MSRV. Tests can require later + # Rust versions, don't compile or run them. + cargo build --no-default-features --verbose + build1_exit=$? + + cargo build --all-features --verbose + build2_exit=$? + + exit $(( $build1_exit || $build2_exit )) diff --git a/ruma-identifiers/.builds/nightly.yml b/ruma-identifiers/.builds/nightly.yml new file mode 100644 index 00000000..e119912a --- /dev/null +++ b/ruma-identifiers/.builds/nightly.yml @@ -0,0 +1,32 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-identifiers +tasks: + - rustup: | + rustup toolchain install nightly --profile minimal + rustup default nightly + + # Try installing rustfmt & clippy for nightly, but don't fail the build + # if they are not available + rustup component add rustfmt || true + rustup component add clippy || true + - test: | + cd ruma-identifiers + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + if ( rustup component list | grep -q rustfmt ); then + cargo fmt -- --check + fi + fmt_exit=$? + + if ( rustup component list | grep -q clippy ); then + cargo clippy --all-targets --all-features -- -D warnings + fi + clippy_exit=$? + + exit $(( $fmt_exit || $clippy_exit )) diff --git a/ruma-identifiers/.builds/stable.yml b/ruma-identifiers/.builds/stable.yml new file mode 100644 index 00000000..57620ca8 --- /dev/null +++ b/ruma-identifiers/.builds/stable.yml @@ -0,0 +1,32 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-identifiers +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install stable --profile minimal -c rustfmt -c clippy + rustup default stable + - test: | + cd ruma-identifiers + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --no-default-features --verbose + test1_exit=$? + + cargo test --all-features --verbose + test2_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test1_exit || $test2_exit )) + # TODO: Add audit task once cargo-audit binary releases are available. + # See https://github.com/RustSec/cargo-audit/issues/66 diff --git a/ruma-identifiers/.gitignore b/ruma-identifiers/.gitignore new file mode 100644 index 00000000..06aba01b --- /dev/null +++ b/ruma-identifiers/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +/target diff --git a/ruma-identifiers/CHANGELOG.md b/ruma-identifiers/CHANGELOG.md new file mode 100644 index 00000000..275e87f2 --- /dev/null +++ b/ruma-identifiers/CHANGELOG.md @@ -0,0 +1,104 @@ +# [unreleased] + +Breaking changes: + +* Removed diesel integration. If you were using it, please comment on the corresponding issue: + https://github.com/ruma/ruma-identifiers/issues/22 +* Remove `TryFrom>` implementations for identifier types +* Update `parse_with_server_name`s signature (instead of `Into` it now requires + `Into>` of the id type). This is technically a breaking change, but extremely unlikely + to affect any existing code. + +Improvements: + +* Add `DeviceKeyId`, `KeyAlgorithm`, and `ServerKeyId` + +# 0.16.2 + +Improvements: + +* Update the internal representation of identifiers to be more compact +* Add `RoomVersionId::version_6` and `RoomVersionId::is_version_6` +* Add `PartialOrd` and `Ord` implementations for `RoomVersionId` + +# 0.16.1 + +Bug fixes: + +* Change `PartialEq` implementations to compare IDs with string literals from `str` to `&str` + * This is technically a breaking change, but the previous implementations were extremely + unlikely to actually be used + +# 0.16.0 + +Breaking changes: + +* Update `RoomId::parse_with_server_name`s bounds from `Into>` to + `AsRef + Into`. While this is a breaking change, it is not expected to actually + require code changes. + +Improvements: + +* Add conversion functions for `RoomIdOrAliasId` + * `impl From for RoomIdOrAliasId` + * `impl From for RoomIdOrAliasId` + * `impl TryFrom for RoomId` + * `impl TryFrom for RoomAliasId` + * `RoomIdOrAliasId::into_either` (if the optional dependency `either` is activated with the + identically named feature) + +# 0.15.1 + +Bug fixes: + +* Fix docs.rs build + +# 0.15.0 + +Breaking changes: + +* All identifiers now allocate at maximum one string (localpart and host are no longer stored + separately) + * Because of this, these traits are now implemented for them and only allocate in the obvious + case: + * `impl From<…Id> for String` + * `impl AsRef for …Id` + * `impl TryFrom> for …Id` + * `impl TryFrom for …Id` + * `PartialEq` for `String`s and string slices + * Additionally, the `Hash` implementations will now yield the same hashes as hashing the string + representation + * Note that hashes are generally only guaranteed consistent in the lifetime of the program + though, so do not persist them! + * The `hostname` methods have been rename to `server_name` and updated to return string slices + instead of `&url::Host` +* `Error::InvalidHost` has been renamed to `Error::InvalidServerName`, because it also covers errors + in the port, not just the host part section of the server name +* The random identifier generation functions (`Id::new`) are now only available if the `rand` + feature of this crate is enabled + +Improvements: + +* Add support for historical uppercase MXIDs +* Made all dependencies optional + * `serde` is the only one that is enabled by default +* The `user_id` module is now public and contains `fn localpart_is_fully_conforming` + * This function can be used to determine whether a user name (the localpart of a user ID) is valid + without actually constructing a full user ID first +* Add `UserId::parse_with_server_name` + +# 0.14.1 + +Breaking changes: + +* Our Minimum Supported Rust Version is now 1.36.0 + * This is done in a patch version because it is only a documentation change. Practially, a new + project using even ruma-identifiers 0.14 won't build out of the box on older versions of Rust + because of an MSRV bump in a minor release of an indirect dependency. Using ruma-identifiers + with older versions of Rust will potentially continue to work with some crates pinned to older + versions, but won't be tested in CI. + +Improvements: + +* Remove the dependency on `lazy_static` and `regex` +* We now support [historical user IDs](https://matrix.org/docs/spec/appendices#historical-user-ids) diff --git a/ruma-identifiers/Cargo.toml b/ruma-identifiers/Cargo.toml new file mode 100644 index 00000000..79c77ff0 --- /dev/null +++ b/ruma-identifiers/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors = ["Jimmy Cuadra "] +categories = ["api-bindings"] +description = "Resource identifiers for Matrix." +documentation = "https://docs.rs/ruma-identifiers" +homepage = "https://github.com/ruma/ruma-identifiers" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-identifiers" +readme = "README.md" +repository = "https://github.com/ruma/ruma-identifiers" +version = "0.16.1" +edition = "2018" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +default = ["serde"] + +[dependencies] +either = { version = "1.5.3", optional = true } +rand = { version = "0.7.3", optional = true } +serde = { version = "1.0.106", optional = true, features = ["derive"] } +strum = { version = "0.18.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.51" diff --git a/ruma-identifiers/LICENSE b/ruma-identifiers/LICENSE new file mode 100644 index 00000000..de62627d --- /dev/null +++ b/ruma-identifiers/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 Jimmy Cuadra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/ruma-identifiers/README.md b/ruma-identifiers/README.md new file mode 100644 index 00000000..34a9f2ed --- /dev/null +++ b/ruma-identifiers/README.md @@ -0,0 +1,15 @@ +# ruma-identifiers + +[![crates.io page](https://img.shields.io/crates/v/ruma-identifiers.svg)](https://crates.io/crates/ruma-identifiers) +[![docs.rs page](https://docs.rs/ruma-identifiers/badge.svg)](https://docs.rs/ruma-identifiers/) +![license: MIT](https://img.shields.io/crates/l/ruma-identifiers.svg) + +**ruma-identifiers** contains types for [Matrix](https://matrix.org/) identifiers for events, rooms, room aliases, and users. + +## Minimum Rust version + +ruma-identifiers requires Rust 1.36.0 or later. + +## Documentation + +ruma-identifiers has [comprehensive documentation](https://docs.rs/ruma-identifiers) available on docs.rs. diff --git a/ruma-identifiers/src/device_id.rs b/ruma-identifiers/src/device_id.rs new file mode 100644 index 00000000..d526f066 --- /dev/null +++ b/ruma-identifiers/src/device_id.rs @@ -0,0 +1,27 @@ +//! Matrix device identifiers. + +#[cfg(feature = "rand")] +use crate::generate_localpart; + +/// A Matrix device ID. +/// +/// Device identifiers in Matrix are completely opaque character sequences. This type alias is +/// provided simply for its semantic value. +pub type DeviceId = String; + +/// Generates a random `DeviceId`, suitable for assignment to a new device. +#[cfg(feature = "rand")] +#[cfg_attr(docsrs, doc(cfg(feature = "rand")))] +pub fn generate() -> DeviceId { + generate_localpart(8) +} + +#[cfg(all(test, feature = "rand"))] +mod tests { + use super::generate; + + #[test] + fn generate_device_id() { + assert_eq!(generate().len(), 8); + } +} diff --git a/ruma-identifiers/src/device_key_id.rs b/ruma-identifiers/src/device_key_id.rs new file mode 100644 index 00000000..419c9398 --- /dev/null +++ b/ruma-identifiers/src/device_key_id.rs @@ -0,0 +1,136 @@ +//! Identifiers for device keys for end-to-end encryption. + +use crate::{device_id::DeviceId, error::Error, key_algorithms::DeviceKeyAlgorithm}; +use std::num::NonZeroU8; +use std::str::FromStr; + +/// A key algorithm and a device id, combined with a ':' +#[derive(Clone, Debug)] +pub struct DeviceKeyId { + full_id: T, + colon_idx: NonZeroU8, +} + +impl DeviceKeyId { + /// Returns key algorithm of the device key ID. + pub fn algorithm(&self) -> DeviceKeyAlgorithm + where + T: AsRef, + { + DeviceKeyAlgorithm::from_str(&self.full_id.as_ref()[..self.colon_idx.get() as usize]) + .unwrap() + } + + /// Returns device ID of the device key ID. + pub fn device_id(&self) -> DeviceId + where + T: AsRef, + { + DeviceId::from(&self.full_id.as_ref()[self.colon_idx.get() as usize + 1..]) + } +} + +fn try_from(key_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let key_str = key_id.as_ref(); + let colon_idx = + NonZeroU8::new(key_str.find(':').ok_or(Error::MissingDeviceKeyDelimiter)? as u8) + .ok_or(Error::UnknownKeyAlgorithm)?; + + DeviceKeyAlgorithm::from_str(&key_str[0..colon_idx.get() as usize]) + .map_err(|_| Error::UnknownKeyAlgorithm)?; + + Ok(DeviceKeyId { + full_id: key_id.into(), + colon_idx, + }) +} + +common_impls!( + DeviceKeyId, + try_from, + "Device key ID with algorithm and device ID" +); + +#[cfg(test)] +mod test { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::DeviceKeyId; + use crate::{device_id::DeviceId, error::Error, key_algorithms::DeviceKeyAlgorithm}; + + #[test] + fn convert_device_key_id() { + assert_eq!( + DeviceKeyId::<&str>::try_from("ed25519:JLAFKJWSCS") + .expect("Failed to create device key ID.") + .as_ref(), + "ed25519:JLAFKJWSCS" + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_device_key_id() { + let device_key_id = DeviceKeyId::<&str>::try_from("ed25519:JLAFKJWSCS").unwrap(); + let serialized = to_json_value(device_key_id).unwrap(); + + let expected = json!("ed25519:JLAFKJWSCS"); + assert_eq!(serialized, expected); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_device_key_id() { + let deserialized: DeviceKeyId<_> = from_json_value(json!("ed25519:JLAFKJWSCS")).unwrap(); + + let expected = DeviceKeyId::try_from("ed25519:JLAFKJWSCS").unwrap(); + assert_eq!(deserialized, expected); + } + + #[test] + fn missing_key_algorithm() { + assert_eq!( + DeviceKeyId::<&str>::try_from(":JLAFKJWSCS").unwrap_err(), + Error::UnknownKeyAlgorithm + ); + } + + #[test] + fn missing_delimiter() { + assert_eq!( + DeviceKeyId::<&str>::try_from("ed25519|JLAFKJWSCS").unwrap_err(), + Error::MissingDeviceKeyDelimiter, + ); + } + + #[test] + fn unknown_key_algorithm() { + assert_eq!( + DeviceKeyId::<&str>::try_from("signed_curve25510:JLAFKJWSCS").unwrap_err(), + Error::UnknownKeyAlgorithm, + ); + } + + #[test] + fn empty_device_id_ok() { + assert!(DeviceKeyId::<&str>::try_from("ed25519:").is_ok()); + } + + #[test] + fn valid_key_algorithm() { + let device_key_id = DeviceKeyId::<&str>::try_from("ed25519:JLAFKJWSCS").unwrap(); + assert_eq!(device_key_id.algorithm(), DeviceKeyAlgorithm::Ed25519); + } + + #[test] + fn valid_device_id() { + let device_key_id = DeviceKeyId::<&str>::try_from("ed25519:JLAFKJWSCS").unwrap(); + assert_eq!(device_key_id.device_id(), DeviceId::from("JLAFKJWSCS")); + } +} diff --git a/ruma-identifiers/src/error.rs b/ruma-identifiers/src/error.rs new file mode 100644 index 00000000..d61017f8 --- /dev/null +++ b/ruma-identifiers/src/error.rs @@ -0,0 +1,54 @@ +//! Error conditions. + +use std::fmt::{self, Display, Formatter}; + +/// An error encountered when trying to parse an invalid ID string. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub enum Error { + /// The ID's localpart contains invalid characters. + /// + /// Only relevant for user IDs. + InvalidCharacters, + /// The key version contains outside of [a-zA-Z0-9_]. + InvalidKeyVersion, + /// The localpart of the ID string is not valid (because it is empty). + InvalidLocalPart, + /// The server name part of the the ID string is not a valid server name. + InvalidServerName, + /// The ID exceeds 255 bytes (or 32 codepoints for a room version ID.) + MaximumLengthExceeded, + /// The ID is less than 4 characters (or is an empty room version ID.) + MinimumLengthNotSatisfied, + /// The ID is missing the colon delimiter between localpart and server name. + MissingDelimiter, + /// The ID is missing the colon delimiter between key algorithm and device ID. + MissingDeviceKeyDelimiter, + /// The ID is missing the colon delimiter between key algorithm and version. + MissingServerKeyDelimiter, + /// The ID is missing the leading sigil. + MissingSigil, + /// The key algorithm is not recognized. + UnknownKeyAlgorithm, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let message = match self { + Error::InvalidCharacters => "localpart contains invalid characters", + Error::InvalidKeyVersion => "key id version contains invalid characters", + Error::InvalidLocalPart => "localpart is empty", + Error::InvalidServerName => "server name is not a valid IP address or domain name", + Error::MaximumLengthExceeded => "ID exceeds 255 bytes", + Error::MinimumLengthNotSatisfied => "ID must be at least 4 characters", + Error::MissingDelimiter => "colon is required between localpart and server name", + Error::MissingDeviceKeyDelimiter => "colon is required between algorithm and device ID", + Error::MissingServerKeyDelimiter => "colon is required between algorithm and version", + Error::MissingSigil => "leading sigil is missing", + Error::UnknownKeyAlgorithm => "unknown key algorithm specified", + }; + + write!(f, "{}", message) + } +} + +impl std::error::Error for Error {} diff --git a/ruma-identifiers/src/event_id.rs b/ruma-identifiers/src/event_id.rs new file mode 100644 index 00000000..77d31857 --- /dev/null +++ b/ruma-identifiers/src/event_id.rs @@ -0,0 +1,315 @@ +//! Matrix event identifiers. + +use std::num::NonZeroU8; + +use crate::{error::Error, parse_id, validate_id}; + +/// A Matrix event ID. +/// +/// An `EventId` is generated randomly or converted from a string slice, and can be converted back +/// into a string as needed. +/// +/// It is discouraged to use this type directly – instead use one of the aliases (`EventId` and +/// `EventIdRef`) in the crate root. +/// +/// # Room versions +/// +/// Matrix specifies multiple [room versions](https://matrix.org/docs/spec/#room-versions) and the +/// format of event identifiers differ between them. The original format used by room versions 1 +/// and 2 uses a short pseudorandom "localpart" followed by the hostname and port of the +/// originating homeserver. Later room versions change event identifiers to be a hash of the event +/// encoded with Base64. Some of the methods provided by `EventId` are only relevant to the +/// original event format. +/// +/// ``` +/// # use std::convert::TryFrom; +/// # use ruma_identifiers::EventId; +/// // Original format +/// assert_eq!( +/// EventId::try_from("$h29iv0s8:example.com").unwrap().as_ref(), +/// "$h29iv0s8:example.com" +/// ); +/// // Room version 3 format +/// assert_eq!( +/// EventId::try_from("$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk").unwrap().as_ref(), +/// "$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk" +/// ); +/// // Room version 4 format +/// assert_eq!( +/// EventId::try_from("$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg").unwrap().as_ref(), +/// "$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg" +/// ); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct EventId { + full_id: T, + colon_idx: Option, +} + +impl EventId { + /// Attempts to generate an `EventId` for the given origin server with a localpart consisting + /// of 18 random ASCII characters. This should only be used for events in the original format + /// as used by Matrix room versions 1 and 2. + /// + /// Does not currently ever fail, but may fail in the future if the homeserver cannot be parsed + /// parsed as a valid host. + #[cfg(feature = "rand")] + #[cfg_attr(docsrs, doc(cfg(feature = "rand")))] + pub fn new(server_name: &str) -> Result + where + String: Into, + { + use crate::{generate_localpart, is_valid_server_name}; + + if !is_valid_server_name(server_name) { + return Err(Error::InvalidServerName); + } + let full_id = format!("${}:{}", generate_localpart(18), server_name).into(); + + Ok(Self { + full_id, + colon_idx: NonZeroU8::new(19), + }) + } + + /// Returns the event's unique ID. For the original event format as used by Matrix room + /// versions 1 and 2, this is the "localpart" that precedes the homeserver. For later formats, + /// this is the entire ID without the leading $ sigil. + pub fn localpart(&self) -> &str + where + T: AsRef, + { + let idx = match self.colon_idx { + Some(idx) => idx.get() as usize, + None => self.full_id.as_ref().len(), + }; + + &self.full_id.as_ref()[1..idx] + } + + /// Returns the server name of the event ID. + /// + /// Only applicable to events in the original format as used by Matrix room versions 1 and 2. + pub fn server_name(&self) -> Option<&str> + where + T: AsRef, + { + self.colon_idx + .map(|idx| &self.full_id.as_ref()[idx.get() as usize + 1..]) + } +} + +/// Attempts to create a new Matrix event ID from a string representation. +/// +/// If using the original event format as used by Matrix room versions 1 and 2, the string must +/// include the leading $ sigil, the localpart, a literal colon, and a valid homeserver hostname. +fn try_from(event_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + if event_id.as_ref().contains(':') { + let colon_idx = parse_id(event_id.as_ref(), &['$'])?; + + Ok(EventId { + full_id: event_id.into(), + colon_idx: Some(colon_idx), + }) + } else { + validate_id(event_id.as_ref(), &['$'])?; + + Ok(EventId { + full_id: event_id.into(), + colon_idx: None, + }) + } +} + +common_impls!(EventId, try_from, "a Matrix event ID"); + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_str, to_string}; + + use crate::error::Error; + + type EventId = super::EventId>; + + #[test] + fn valid_original_event_id() { + assert_eq!( + EventId::try_from("$39hvsi03hlne:example.com") + .expect("Failed to create EventId.") + .as_ref(), + "$39hvsi03hlne:example.com" + ); + } + + #[test] + fn valid_base64_event_id() { + assert_eq!( + EventId::try_from("$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk") + .expect("Failed to create EventId.") + .as_ref(), + "$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk" + ) + } + + #[test] + fn valid_url_safe_base64_event_id() { + assert_eq!( + EventId::try_from("$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg") + .expect("Failed to create EventId.") + .as_ref(), + "$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg" + ) + } + + #[cfg(feature = "rand")] + #[test] + fn generate_random_valid_event_id() { + let event_id = EventId::new("example.com").expect("Failed to generate EventId."); + let id_str: &str = event_id.as_ref(); + + assert!(id_str.starts_with('$')); + assert_eq!(id_str.len(), 31); + } + + #[cfg(feature = "rand")] + #[test] + fn generate_random_invalid_event_id() { + assert!(EventId::new("").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_original_event_id() { + assert_eq!( + to_string( + &EventId::try_from("$39hvsi03hlne:example.com").expect("Failed to create EventId.") + ) + .expect("Failed to convert EventId to JSON."), + r#""$39hvsi03hlne:example.com""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_base64_event_id() { + assert_eq!( + to_string( + &EventId::try_from("$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk") + .expect("Failed to create EventId.") + ) + .expect("Failed to convert EventId to JSON."), + r#""$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_url_safe_base64_event_id() { + assert_eq!( + to_string( + &EventId::try_from("$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg") + .expect("Failed to create EventId.") + ) + .expect("Failed to convert EventId to JSON."), + r#""$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_original_event_id() { + assert_eq!( + from_str::(r#""$39hvsi03hlne:example.com""#) + .expect("Failed to convert JSON to EventId"), + EventId::try_from("$39hvsi03hlne:example.com").expect("Failed to create EventId.") + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_base64_event_id() { + assert_eq!( + from_str::(r#""$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk""#) + .expect("Failed to convert JSON to EventId"), + EventId::try_from("$acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk") + .expect("Failed to create EventId.") + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_url_safe_base64_event_id() { + assert_eq!( + from_str::(r#""$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg""#) + .expect("Failed to convert JSON to EventId"), + EventId::try_from("$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg") + .expect("Failed to create EventId.") + ); + } + + #[test] + fn valid_original_event_id_with_explicit_standard_port() { + assert_eq!( + EventId::try_from("$39hvsi03hlne:example.com:443") + .expect("Failed to create EventId.") + .as_ref(), + "$39hvsi03hlne:example.com:443" + ); + } + + #[test] + fn valid_original_event_id_with_non_standard_port() { + assert_eq!( + EventId::try_from("$39hvsi03hlne:example.com:5000") + .expect("Failed to create EventId.") + .as_ref(), + "$39hvsi03hlne:example.com:5000" + ); + } + + #[test] + fn missing_original_event_id_sigil() { + assert_eq!( + EventId::try_from("39hvsi03hlne:example.com").unwrap_err(), + Error::MissingSigil + ); + } + + #[test] + fn missing_base64_event_id_sigil() { + assert_eq!( + EventId::try_from("acR1l0raoZnm60CBwAVgqbZqoO/mYU81xysh1u7XcJk").unwrap_err(), + Error::MissingSigil + ); + } + + #[test] + fn missing_url_safe_base64_event_id_sigil() { + assert_eq!( + EventId::try_from("Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg").unwrap_err(), + Error::MissingSigil + ); + } + + #[test] + fn invalid_event_id_host() { + assert_eq!( + EventId::try_from("$39hvsi03hlne:/").unwrap_err(), + Error::InvalidServerName + ); + } + + #[test] + fn invalid_event_id_port() { + assert_eq!( + EventId::try_from("$39hvsi03hlne:example.com:notaport").unwrap_err(), + Error::InvalidServerName + ); + } +} diff --git a/ruma-identifiers/src/key_algorithms.rs b/ruma-identifiers/src/key_algorithms.rs new file mode 100644 index 00000000..a4879b6e --- /dev/null +++ b/ruma-identifiers/src/key_algorithms.rs @@ -0,0 +1,33 @@ +//! Key algorithms used in Matrix spec. + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use strum::{AsRefStr, Display, EnumString}; + +/// The basic key algorithms in the specification +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, AsRefStr, Display, EnumString)] +#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] +#[non_exhaustive] +pub enum DeviceKeyAlgorithm { + /// The Ed25519 signature algorithm. + #[strum(to_string = "ed25519")] + Ed25519, + + /// The Curve25519 ECDH algorithm. + #[strum(to_string = "curve25519")] + Curve25519, + + /// The Curve25519 ECDH algorithm, but the key also contains signatures + #[strum(to_string = "signed_curve25519")] + SignedCurve25519, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, AsRefStr, Display, EnumString)] +#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] +#[non_exhaustive] +pub enum ServerKeyAlgorithm { + /// The Ed25519 signature algorithm. + #[strum(to_string = "ed25519")] + Ed25519, +} diff --git a/ruma-identifiers/src/lib.rs b/ruma-identifiers/src/lib.rs new file mode 100644 index 00000000..09f378e6 --- /dev/null +++ b/ruma-identifiers/src/lib.rs @@ -0,0 +1,171 @@ +//! Crate **ruma_identifiers** contains types for [Matrix](https://matrix.org/) identifiers +//! for events, rooms, room aliases, room versions, and users. + +#![warn(rust_2018_idioms)] +#![deny( + missing_copy_implementations, + missing_debug_implementations, + //missing_docs +)] +// Since we support Rust 1.36.0, we can't apply this suggestion yet +#![allow(clippy::use_self)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +use std::num::NonZeroU8; + +#[cfg(feature = "serde")] +use serde::de::{self, Deserialize as _, Deserializer, Unexpected}; + +#[doc(inline)] +pub use crate::{error::Error, server_name::is_valid_server_name}; + +#[macro_use] +mod macros; + +mod error; +mod server_name; + +pub mod device_id; +pub mod device_key_id; +pub mod event_id; +pub mod key_algorithms; +pub mod room_alias_id; +pub mod room_id; +pub mod room_id_or_room_alias_id; +pub mod room_version_id; +pub mod server_key_id; +pub mod user_id; + +/// An owned event ID. +/// +/// Can be created via `new` (if the `rand` feature is enabled) and `TryFrom` + +/// `TryFrom<&str>`, implements `Serialize` and `Deserialize` if the `serde` feature is enabled. +pub type EventId = event_id::EventId>; + +/// A reference to an event ID. +/// +/// Can be created via `TryFrom<&str>`, implements `Serialize` if the `serde` feature is enabled. +pub type EventIdRef<'a> = event_id::EventId<&'a str>; + +/// An owned room alias ID. +/// +/// Can be created via `TryFrom` and `TryFrom<&str>`, implements `Serialize` and +/// `Deserialize` if the `serde` feature is enabled. +pub type RoomAliasId = room_alias_id::RoomAliasId>; + +/// A reference to a room alias ID. +/// +/// Can be created via `TryFrom<&str>`, implements `Serialize` if the `serde` feature is enabled. +pub type RoomAliasIdRef<'a> = room_alias_id::RoomAliasId<&'a str>; + +/// An owned room ID. +/// +/// Can be created via `new` (if the `rand` feature is enabled) and `TryFrom` + +/// `TryFrom<&str>`, implements `Serialize` and `Deserialize` if the `serde` feature is enabled. +pub type RoomId = room_id::RoomId>; + +/// A reference to a room ID. +/// +/// Can be created via `TryFrom<&str>`, implements `Serialize` if the `serde` feature is enabled. +pub type RoomIdRef<'a> = room_id::RoomId<&'a str>; + +/// An owned room alias ID or room ID. +/// +/// Can be created via `TryFrom`, `TryFrom<&str>`, `From` and `From`; +/// implements `Serialize` and `Deserialize` if the `serde` feature is enabled. +pub type RoomIdOrAliasId = room_id_or_room_alias_id::RoomIdOrAliasId>; + +/// A reference to a room alias ID or room ID. +/// +/// Can be created via `TryFrom<&str>`, `From` and `From`; implements +/// `Serialize` if the `serde` feature is enabled. +pub type RoomIdOrAliasIdRef<'a> = room_id_or_room_alias_id::RoomIdOrAliasId<&'a str>; + +/// An owned room version ID. +/// +/// Can be created using the `version_N` constructor functions, `TryFrom` and +/// `TryFrom<&str>`; implements `Serialize` and `Deserialize` if the `serde` feature is enabled. +pub type RoomVersionId = room_version_id::RoomVersionId>; + +/// A reference to a room version ID. +/// +/// Can be created using the `version_N` constructor functions and via `TryFrom<&str>`, implements +/// `Serialize` if the `serde` feature is enabled. +pub type RoomVersionIdRef<'a> = room_version_id::RoomVersionId<&'a str>; + +/// An owned user ID. +/// +/// Can be created via `new` (if the `rand` feature is enabled) and `TryFrom` + +/// `TryFrom<&str>`, implements `Serialize` and `Deserialize` if the `serde` feature is enabled. +pub type UserId = user_id::UserId>; + +/// A reference to a user ID. +/// +/// Can be created via `TryFrom<&str>`, implements `Serialize` if the `serde` feature is enabled. +pub type UserIdRef<'a> = user_id::UserId<&'a str>; + +/// All identifiers must be 255 bytes or less. +const MAX_BYTES: usize = 255; +/// The minimum number of characters an ID can be. +/// +/// This is an optimization and not required by the spec. The shortest possible valid ID is a sigil +/// + a single character local ID + a colon + a single character hostname. +const MIN_CHARS: usize = 4; + +/// Generates a random identifier localpart. +#[cfg(feature = "rand")] +fn generate_localpart(length: usize) -> String { + use rand::Rng as _; + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(length) + .collect() +} + +/// Checks if an identifier is valid. +fn validate_id(id: &str, valid_sigils: &[char]) -> Result<(), Error> { + if id.len() > MAX_BYTES { + return Err(Error::MaximumLengthExceeded); + } + + if id.len() < MIN_CHARS { + return Err(Error::MinimumLengthNotSatisfied); + } + + if !valid_sigils.contains(&id.chars().next().unwrap()) { + return Err(Error::MissingSigil); + } + + Ok(()) +} + +/// Checks an identifier that contains a localpart and hostname for validity, +/// and returns the index of the colon that separates the two. +fn parse_id(id: &str, valid_sigils: &[char]) -> Result { + validate_id(id, valid_sigils)?; + + let colon_idx = id.find(':').ok_or(Error::MissingDelimiter)?; + if colon_idx < 2 { + return Err(Error::InvalidLocalPart); + } + + if !is_valid_server_name(&id[colon_idx + 1..]) { + return Err(Error::InvalidServerName); + } + + Ok(NonZeroU8::new(colon_idx as u8).unwrap()) +} + +/// Deserializes any type of id using the provided TryFrom implementation. +/// +/// This is a helper function to reduce the boilerplate of the Deserialize implementations. +#[cfg(feature = "serde")] +fn deserialize_id<'de, D, T>(deserializer: D, expected_str: &str) -> Result +where + D: Deserializer<'de>, + T: for<'a> std::convert::TryFrom<&'a str>, +{ + std::borrow::Cow::<'_, str>::deserialize(deserializer).and_then(|v| { + T::try_from(&v).map_err(|_| de::Error::invalid_value(Unexpected::Str(&v), &expected_str)) + }) +} diff --git a/ruma-identifiers/src/macros.rs b/ruma-identifiers/src/macros.rs new file mode 100644 index 00000000..a072a6aa --- /dev/null +++ b/ruma-identifiers/src/macros.rs @@ -0,0 +1,115 @@ +macro_rules! common_impls { + ($id:ident, $try_from:ident, $desc:literal) => { + impl ::std::convert::From<$id>> for ::std::string::String { + fn from(id: $id>) -> Self { + id.full_id.into() + } + } + + impl<'a> ::std::convert::TryFrom<&'a str> for $id<&'a str> { + type Error = crate::error::Error; + + fn try_from(s: &'a str) -> Result { + $try_from(s) + } + } + + impl ::std::convert::TryFrom<&str> for $id> { + type Error = crate::error::Error; + + fn try_from(s: &str) -> Result { + $try_from(s) + } + } + + impl ::std::convert::TryFrom for $id> { + type Error = crate::error::Error; + + fn try_from(s: String) -> Result { + $try_from(s) + } + } + + impl> ::std::convert::AsRef for $id { + fn as_ref(&self) -> &str { + self.full_id.as_ref() + } + } + + impl ::std::fmt::Display for $id { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + write!(f, "{}", self.full_id) + } + } + + impl ::std::cmp::PartialEq for $id { + fn eq(&self, other: &Self) -> bool { + self.full_id == other.full_id + } + } + + impl ::std::cmp::Eq for $id {} + + impl ::std::cmp::PartialOrd for $id { + fn partial_cmp(&self, other: &Self) -> Option<::std::cmp::Ordering> { + ::std::cmp::PartialOrd::partial_cmp(&self.full_id, &other.full_id) + } + } + + impl ::std::cmp::Ord for $id { + fn cmp(&self, other: &Self) -> ::std::cmp::Ordering { + ::std::cmp::Ord::cmp(&self.full_id, &other.full_id) + } + } + + impl ::std::hash::Hash for $id { + fn hash(&self, state: &mut H) { + self.full_id.hash(state); + } + } + + #[cfg(feature = "serde")] + impl> ::serde::Serialize for $id { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::Serializer, + { + serializer.serialize_str(self.full_id.as_ref()) + } + } + + #[cfg(feature = "serde")] + impl<'de> ::serde::Deserialize<'de> for $id> { + fn deserialize(deserializer: D) -> Result + where + D: ::serde::Deserializer<'de>, + { + crate::deserialize_id(deserializer, $desc) + } + } + + impl> ::std::cmp::PartialEq<&str> for $id { + fn eq(&self, other: &&str) -> bool { + self.full_id.as_ref() == *other + } + } + + impl> ::std::cmp::PartialEq<$id> for &str { + fn eq(&self, other: &$id) -> bool { + *self == other.full_id.as_ref() + } + } + + impl> ::std::cmp::PartialEq<::std::string::String> for $id { + fn eq(&self, other: &::std::string::String) -> bool { + self.full_id.as_ref() == &other[..] + } + } + + impl> ::std::cmp::PartialEq<$id> for ::std::string::String { + fn eq(&self, other: &$id) -> bool { + &self[..] == other.full_id.as_ref() + } + } + }; +} diff --git a/ruma-identifiers/src/room_alias_id.rs b/ruma-identifiers/src/room_alias_id.rs new file mode 100644 index 00000000..b18ccc00 --- /dev/null +++ b/ruma-identifiers/src/room_alias_id.rs @@ -0,0 +1,170 @@ +//! Matrix room alias identifiers. + +use std::num::NonZeroU8; + +use crate::{error::Error, parse_id}; + +/// A Matrix room alias ID. +/// +/// It is discouraged to use this type directly – instead use one of the aliases (`RoomAliasId` and +/// `RoomAliasIdRef`) in the crate root. +/// +/// A `RoomAliasId` is converted from a string slice, and can be converted back into a string as +/// needed. +/// +/// ``` +/// # use std::convert::TryFrom; +/// # use ruma_identifiers::RoomAliasId; +/// assert_eq!( +/// RoomAliasId::try_from("#ruma:example.com").unwrap().as_ref(), +/// "#ruma:example.com" +/// ); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct RoomAliasId { + pub(crate) full_id: T, + pub(crate) colon_idx: NonZeroU8, +} + +impl> RoomAliasId { + /// Returns the room's alias. + pub fn alias(&self) -> &str { + &self.full_id.as_ref()[1..self.colon_idx.get() as usize] + } + + /// Returns the server name of the room alias ID. + pub fn server_name(&self) -> &str { + &self.full_id.as_ref()[self.colon_idx.get() as usize + 1..] + } +} + +/// Attempts to create a new Matrix room alias ID from a string representation. +/// +/// The string must include the leading # sigil, the alias, a literal colon, and a server name. +fn try_from(room_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let colon_idx = parse_id(room_id.as_ref(), &['#'])?; + + Ok(RoomAliasId { + full_id: room_id.into(), + colon_idx, + }) +} + +common_impls!(RoomAliasId, try_from, "a Matrix room alias ID"); + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_str, to_string}; + + use crate::error::Error; + + type RoomAliasId = super::RoomAliasId>; + + #[test] + fn valid_room_alias_id() { + assert_eq!( + RoomAliasId::try_from("#ruma:example.com") + .expect("Failed to create RoomAliasId.") + .as_ref(), + "#ruma:example.com" + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_room_alias_id() { + assert_eq!( + to_string( + &RoomAliasId::try_from("#ruma:example.com").expect("Failed to create RoomAliasId.") + ) + .expect("Failed to convert RoomAliasId to JSON."), + r##""#ruma:example.com""## + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_room_alias_id() { + assert_eq!( + from_str::(r##""#ruma:example.com""##) + .expect("Failed to convert JSON to RoomAliasId"), + RoomAliasId::try_from("#ruma:example.com").expect("Failed to create RoomAliasId.") + ); + } + + #[test] + fn valid_room_alias_id_with_explicit_standard_port() { + assert_eq!( + RoomAliasId::try_from("#ruma:example.com:443") + .expect("Failed to create RoomAliasId.") + .as_ref(), + "#ruma:example.com:443" + ); + } + + #[test] + fn valid_room_alias_id_with_non_standard_port() { + assert_eq!( + RoomAliasId::try_from("#ruma:example.com:5000") + .expect("Failed to create RoomAliasId.") + .as_ref(), + "#ruma:example.com:5000" + ); + } + + #[test] + fn valid_room_alias_id_unicode() { + assert_eq!( + RoomAliasId::try_from("#老虎£я:example.com") + .expect("Failed to create RoomAliasId.") + .as_ref(), + "#老虎£я:example.com" + ); + } + + #[test] + fn missing_room_alias_id_sigil() { + assert_eq!( + RoomAliasId::try_from("39hvsi03hlne:example.com").unwrap_err(), + Error::MissingSigil + ); + } + + #[test] + fn missing_localpart() { + assert_eq!( + RoomAliasId::try_from("#:example.com").unwrap_err(), + Error::InvalidLocalPart + ); + } + + #[test] + fn missing_room_alias_id_delimiter() { + assert_eq!( + RoomAliasId::try_from("#ruma").unwrap_err(), + Error::MissingDelimiter + ); + } + + #[test] + fn invalid_room_alias_id_host() { + assert_eq!( + RoomAliasId::try_from("#ruma:/").unwrap_err(), + Error::InvalidServerName + ); + } + + #[test] + fn invalid_room_alias_id_port() { + assert_eq!( + RoomAliasId::try_from("#ruma:example.com:notaport").unwrap_err(), + Error::InvalidServerName + ); + } +} diff --git a/ruma-identifiers/src/room_id.rs b/ruma-identifiers/src/room_id.rs new file mode 100644 index 00000000..07fdfee6 --- /dev/null +++ b/ruma-identifiers/src/room_id.rs @@ -0,0 +1,197 @@ +//! Matrix room identifiers. + +use std::num::NonZeroU8; + +use crate::{error::Error, parse_id}; + +/// A Matrix room ID. +/// +/// A `RoomId` is generated randomly or converted from a string slice, and can be converted back +/// into a string as needed. +/// +/// It is discouraged to use this type directly – instead use one of the aliases (`RoomId` and +/// `RoomIdRef`) in the crate root. +/// +/// ``` +/// # use std::convert::TryFrom; +/// # use ruma_identifiers::RoomId; +/// assert_eq!( +/// RoomId::try_from("!n8f893n9:example.com").unwrap().as_ref(), +/// "!n8f893n9:example.com" +/// ); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct RoomId { + pub(crate) full_id: T, + pub(crate) colon_idx: NonZeroU8, +} + +impl RoomId { + /// Attempts to generate a `RoomId` for the given origin server with a localpart consisting of + /// 18 random ASCII characters. + /// + /// Fails if the given homeserver cannot be parsed as a valid host. + #[cfg(feature = "rand")] + #[cfg_attr(docsrs, doc(cfg(feature = "rand")))] + pub fn new(server_name: &str) -> Result + where + String: Into, + { + use crate::{generate_localpart, is_valid_server_name}; + + if !is_valid_server_name(server_name) { + return Err(Error::InvalidServerName); + } + let full_id = format!("!{}:{}", generate_localpart(18), server_name).into(); + + Ok(Self { + full_id, + colon_idx: NonZeroU8::new(19).unwrap(), + }) + } + + /// Returns the rooms's unique ID. + pub fn localpart(&self) -> &str + where + T: AsRef, + { + &self.full_id.as_ref()[1..self.colon_idx.get() as usize] + } + + /// Returns the server name of the room ID. + pub fn server_name(&self) -> &str + where + T: AsRef, + { + &self.full_id.as_ref()[self.colon_idx.get() as usize + 1..] + } +} + +/// Attempts to create a new Matrix room ID from a string representation. +/// +/// The string must include the leading ! sigil, the localpart, a literal colon, and a server name. +fn try_from(room_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let colon_idx = parse_id(room_id.as_ref(), &['!'])?; + + Ok(RoomId { + full_id: room_id.into(), + colon_idx, + }) +} + +common_impls!(RoomId, try_from, "a Matrix room ID"); + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_str, to_string}; + + use crate::error::Error; + + type RoomId = super::RoomId>; + + #[test] + fn valid_room_id() { + assert_eq!( + RoomId::try_from("!29fhd83h92h0:example.com") + .expect("Failed to create RoomId.") + .as_ref(), + "!29fhd83h92h0:example.com" + ); + } + + #[cfg(feature = "rand")] + #[test] + fn generate_random_valid_room_id() { + let room_id = RoomId::new("example.com").expect("Failed to generate RoomId."); + let id_str: &str = room_id.as_ref(); + + assert!(id_str.starts_with('!')); + assert_eq!(id_str.len(), 31); + } + + #[cfg(feature = "rand")] + #[test] + fn generate_random_invalid_room_id() { + assert!(RoomId::new("").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_room_id() { + assert_eq!( + to_string( + &RoomId::try_from("!29fhd83h92h0:example.com").expect("Failed to create RoomId.") + ) + .expect("Failed to convert RoomId to JSON."), + r#""!29fhd83h92h0:example.com""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_room_id() { + assert_eq!( + from_str::(r#""!29fhd83h92h0:example.com""#) + .expect("Failed to convert JSON to RoomId"), + RoomId::try_from("!29fhd83h92h0:example.com").expect("Failed to create RoomId.") + ); + } + + #[test] + fn valid_room_id_with_explicit_standard_port() { + assert_eq!( + RoomId::try_from("!29fhd83h92h0:example.com:443") + .expect("Failed to create RoomId.") + .as_ref(), + "!29fhd83h92h0:example.com:443" + ); + } + + #[test] + fn valid_room_id_with_non_standard_port() { + assert_eq!( + RoomId::try_from("!29fhd83h92h0:example.com:5000") + .expect("Failed to create RoomId.") + .as_ref(), + "!29fhd83h92h0:example.com:5000" + ); + } + + #[test] + fn missing_room_id_sigil() { + assert_eq!( + RoomId::try_from("carl:example.com").unwrap_err(), + Error::MissingSigil + ); + } + + #[test] + fn missing_room_id_delimiter() { + assert_eq!( + RoomId::try_from("!29fhd83h92h0").unwrap_err(), + Error::MissingDelimiter + ); + } + + #[test] + fn invalid_room_id_host() { + assert_eq!( + RoomId::try_from("!29fhd83h92h0:/").unwrap_err(), + Error::InvalidServerName + ); + } + + #[test] + fn invalid_room_id_port() { + assert_eq!( + RoomId::try_from("!29fhd83h92h0:example.com:notaport").unwrap_err(), + Error::InvalidServerName + ); + } +} diff --git a/ruma-identifiers/src/room_id_or_room_alias_id.rs b/ruma-identifiers/src/room_id_or_room_alias_id.rs new file mode 100644 index 00000000..5e1dcf0c --- /dev/null +++ b/ruma-identifiers/src/room_id_or_room_alias_id.rs @@ -0,0 +1,240 @@ +//! Matrix identifiers for places where a room ID or room alias ID are used interchangeably. + +use std::{convert::TryFrom, hint::unreachable_unchecked, num::NonZeroU8}; + +use crate::{error::Error, parse_id, room_alias_id::RoomAliasId, room_id::RoomId}; + +/// A Matrix room ID or a Matrix room alias ID. +/// +/// `RoomIdOrAliasId` is useful for APIs that accept either kind of room identifier. It is converted +/// from a string slice, and can be converted back into a string as needed. When converted from a +/// string slice, the variant is determined by the leading sigil character. +/// +/// It is discouraged to use this type directly – instead use one of the aliases +/// (`RoomIdOrRoomAliasId` and `RoomIdOrRoomAliasIdRef`) in the crate root. +/// +/// ``` +/// # use std::convert::TryFrom; +/// # use ruma_identifiers::RoomIdOrAliasId; +/// assert_eq!( +/// RoomIdOrAliasId::try_from("#ruma:example.com").unwrap().as_ref(), +/// "#ruma:example.com" +/// ); +/// +/// assert_eq!( +/// RoomIdOrAliasId::try_from("!n8f893n9:example.com").unwrap().as_ref(), +/// "!n8f893n9:example.com" +/// ); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct RoomIdOrAliasId { + full_id: T, + colon_idx: NonZeroU8, +} + +impl> RoomIdOrAliasId { + /// Returns the local part (everything after the `!` or `#` and before the first colon). + pub fn localpart(&self) -> &str { + &self.full_id.as_ref()[1..self.colon_idx.get() as usize] + } + + /// Returns the server name of the room (alias) ID. + pub fn server_name(&self) -> &str { + &self.full_id.as_ref()[self.colon_idx.get() as usize + 1..] + } + + /// Whether this is a room id (starts with `'!'`) + pub fn is_room_id(&self) -> bool { + self.variant() == Variant::RoomId + } + + /// Whether this is a room alias id (starts with `'#'`) + pub fn is_room_alias_id(&self) -> bool { + self.variant() == Variant::RoomAliasId + } + + /// Turn this `RoomIdOrAliasId` into `Either` + #[cfg(feature = "either")] + #[cfg_attr(docsrs, doc(cfg(feature = "either")))] + pub fn into_either(self) -> either::Either, RoomAliasId> { + match self.variant() { + Variant::RoomId => either::Either::Left(RoomId { + full_id: self.full_id, + colon_idx: self.colon_idx, + }), + Variant::RoomAliasId => either::Either::Right(RoomAliasId { + full_id: self.full_id, + colon_idx: self.colon_idx, + }), + } + } + + fn variant(&self) -> Variant { + match self.full_id.as_ref().bytes().next() { + Some(b'!') => Variant::RoomId, + Some(b'#') => Variant::RoomAliasId, + _ => unsafe { unreachable_unchecked() }, + } + } +} + +#[derive(PartialEq)] +enum Variant { + RoomId, + RoomAliasId, +} + +/// Attempts to create a new Matrix room ID or a room alias ID from a string representation. +/// +/// The string must either include the leading ! sigil, the localpart, a literal colon, and a +/// valid homeserver host or include the leading # sigil, the alias, a literal colon, and a +/// valid homeserver host. +fn try_from(room_id_or_alias_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let colon_idx = parse_id(room_id_or_alias_id.as_ref(), &['#', '!'])?; + Ok(RoomIdOrAliasId { + full_id: room_id_or_alias_id.into(), + colon_idx, + }) +} + +common_impls!( + RoomIdOrAliasId, + try_from, + "a Matrix room ID or room alias ID" +); + +impl From> for RoomIdOrAliasId { + fn from(RoomId { full_id, colon_idx }: RoomId) -> Self { + Self { full_id, colon_idx } + } +} + +impl From> for RoomIdOrAliasId { + fn from(RoomAliasId { full_id, colon_idx }: RoomAliasId) -> Self { + Self { full_id, colon_idx } + } +} + +impl> TryFrom> for RoomId { + type Error = RoomAliasId; + + fn try_from(id: RoomIdOrAliasId) -> Result, RoomAliasId> { + match id.variant() { + Variant::RoomId => Ok(RoomId { + full_id: id.full_id, + colon_idx: id.colon_idx, + }), + Variant::RoomAliasId => Err(RoomAliasId { + full_id: id.full_id, + colon_idx: id.colon_idx, + }), + } + } +} + +impl> TryFrom> for RoomAliasId { + type Error = RoomId; + + fn try_from(id: RoomIdOrAliasId) -> Result, RoomId> { + match id.variant() { + Variant::RoomAliasId => Ok(RoomAliasId { + full_id: id.full_id, + colon_idx: id.colon_idx, + }), + Variant::RoomId => Err(RoomId { + full_id: id.full_id, + colon_idx: id.colon_idx, + }), + } + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_str, to_string}; + + use crate::error::Error; + + type RoomIdOrAliasId = super::RoomIdOrAliasId>; + + #[test] + fn valid_room_id_or_alias_id_with_a_room_alias_id() { + assert_eq!( + RoomIdOrAliasId::try_from("#ruma:example.com") + .expect("Failed to create RoomAliasId.") + .as_ref(), + "#ruma:example.com" + ); + } + + #[test] + fn valid_room_id_or_alias_id_with_a_room_id() { + assert_eq!( + RoomIdOrAliasId::try_from("!29fhd83h92h0:example.com") + .expect("Failed to create RoomId.") + .as_ref(), + "!29fhd83h92h0:example.com" + ); + } + + #[test] + fn missing_sigil_for_room_id_or_alias_id() { + assert_eq!( + RoomIdOrAliasId::try_from("ruma:example.com").unwrap_err(), + Error::MissingSigil + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_room_id_or_alias_id_with_a_room_alias_id() { + assert_eq!( + to_string( + &RoomIdOrAliasId::try_from("#ruma:example.com") + .expect("Failed to create RoomAliasId.") + ) + .expect("Failed to convert RoomAliasId to JSON."), + r##""#ruma:example.com""## + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_room_id_or_alias_id_with_a_room_id() { + assert_eq!( + to_string( + &RoomIdOrAliasId::try_from("!29fhd83h92h0:example.com") + .expect("Failed to create RoomId.") + ) + .expect("Failed to convert RoomId to JSON."), + r#""!29fhd83h92h0:example.com""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_room_id_or_alias_id_with_a_room_alias_id() { + assert_eq!( + from_str::(r##""#ruma:example.com""##) + .expect("Failed to convert JSON to RoomAliasId"), + RoomIdOrAliasId::try_from("#ruma:example.com").expect("Failed to create RoomAliasId.") + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_room_id_or_alias_id_with_a_room_id() { + assert_eq!( + from_str::(r##""!29fhd83h92h0:example.com""##) + .expect("Failed to convert JSON to RoomId"), + RoomIdOrAliasId::try_from("!29fhd83h92h0:example.com") + .expect("Failed to create RoomAliasId.") + ); + } +} diff --git a/ruma-identifiers/src/room_version_id.rs b/ruma-identifiers/src/room_version_id.rs new file mode 100644 index 00000000..742b2cc8 --- /dev/null +++ b/ruma-identifiers/src/room_version_id.rs @@ -0,0 +1,470 @@ +//! Matrix room version identifiers. + +use std::{ + cmp::Ordering, + convert::TryFrom, + fmt::{self, Display, Formatter}, +}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::error::Error; + +/// Room version identifiers cannot be more than 32 code points. +const MAX_CODE_POINTS: usize = 32; + +/// A Matrix room version ID. +/// +/// A `RoomVersionId` can be or converted or deserialized from a string slice, and can be converted +/// or serialized back into a string as needed. +/// +/// It is discouraged to use this type directly – instead use one of the aliases (`RoomVersionId` +/// and `RoomVersionIdRef`) in the crate root. +/// +/// ``` +/// # use std::convert::TryFrom; +/// # use ruma_identifiers::RoomVersionId; +/// assert_eq!(RoomVersionId::try_from("1").unwrap().as_ref(), "1"); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RoomVersionId(InnerRoomVersionId); + +/// Possibile values for room version, distinguishing between official Matrix versions and custom +/// versions. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum InnerRoomVersionId { + /// A version 1 room. + Version1, + + /// A version 2 room. + Version2, + + /// A version 3 room. + Version3, + + /// A version 4 room. + Version4, + + /// A version 5 room. + Version5, + + /// A version 6 room. + Version6, + + /// A custom room version. + Custom(T), +} + +impl RoomVersionId { + /// Creates a version 1 room ID. + pub fn version_1() -> Self { + Self(InnerRoomVersionId::Version1) + } + + /// Creates a version 2 room ID. + pub fn version_2() -> Self { + Self(InnerRoomVersionId::Version2) + } + + /// Creates a version 3 room ID. + pub fn version_3() -> Self { + Self(InnerRoomVersionId::Version3) + } + + /// Creates a version 4 room ID. + pub fn version_4() -> Self { + Self(InnerRoomVersionId::Version4) + } + + /// Creates a version 5 room ID. + pub fn version_5() -> Self { + Self(InnerRoomVersionId::Version5) + } + + /// Creates a version 6 room ID. + pub fn version_6() -> Self { + Self(InnerRoomVersionId::Version6) + } + + /// Creates a custom room version ID from the given string slice. + pub fn custom(id: String) -> Self + where + String: Into, + { + Self(InnerRoomVersionId::Custom(id.into())) + } + + /// Whether or not this room version is an official one specified by the Matrix protocol. + pub fn is_official(&self) -> bool { + !self.is_custom() + } + + /// Whether or not this is a custom room version. + pub fn is_custom(&self) -> bool { + match self.0 { + InnerRoomVersionId::Custom(_) => true, + _ => false, + } + } + + /// Whether or not this is a version 1 room. + pub fn is_version_1(&self) -> bool { + matches!(self.0, InnerRoomVersionId::Version1) + } + + /// Whether or not this is a version 2 room. + pub fn is_version_2(&self) -> bool { + matches!(self.0, InnerRoomVersionId::Version2) + } + + /// Whether or not this is a version 3 room. + pub fn is_version_3(&self) -> bool { + matches!(self.0, InnerRoomVersionId::Version3) + } + + /// Whether or not this is a version 4 room. + pub fn is_version_4(&self) -> bool { + matches!(self.0, InnerRoomVersionId::Version4) + } + + /// Whether or not this is a version 5 room. + pub fn is_version_5(&self) -> bool { + matches!(self.0, InnerRoomVersionId::Version5) + } + + /// Whether or not this is a version 6 room. + pub fn is_version_6(&self) -> bool { + matches!(self.0, InnerRoomVersionId::Version5) + } +} + +impl From>> for String { + fn from(id: RoomVersionId>) -> Self { + match id.0 { + InnerRoomVersionId::Version1 => "1".to_owned(), + InnerRoomVersionId::Version2 => "2".to_owned(), + InnerRoomVersionId::Version3 => "3".to_owned(), + InnerRoomVersionId::Version4 => "4".to_owned(), + InnerRoomVersionId::Version5 => "5".to_owned(), + InnerRoomVersionId::Version6 => "6".to_owned(), + InnerRoomVersionId::Custom(version) => version.into(), + } + } +} + +impl> AsRef for RoomVersionId { + fn as_ref(&self) -> &str { + match &self.0 { + InnerRoomVersionId::Version1 => "1", + InnerRoomVersionId::Version2 => "2", + InnerRoomVersionId::Version3 => "3", + InnerRoomVersionId::Version4 => "4", + InnerRoomVersionId::Version5 => "5", + InnerRoomVersionId::Version6 => "6", + InnerRoomVersionId::Custom(version) => version.as_ref(), + } + } +} + +impl> Display for RoomVersionId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + +impl> PartialOrd for RoomVersionId { + fn partial_cmp(&self, other: &RoomVersionId) -> Option { + self.as_ref().partial_cmp(other.as_ref()) + } +} + +impl> Ord for RoomVersionId { + fn cmp(&self, other: &Self) -> Ordering { + self.as_ref().cmp(other.as_ref()) + } +} + +#[cfg(feature = "serde")] +impl> Serialize for RoomVersionId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_ref()) + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for RoomVersionId> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + crate::deserialize_id(deserializer, "a Matrix room version ID as a string") + } +} + +/// Attempts to create a new Matrix room version ID from a string representation. +fn try_from(room_version_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let version = match room_version_id.as_ref() { + "1" => RoomVersionId(InnerRoomVersionId::Version1), + "2" => RoomVersionId(InnerRoomVersionId::Version2), + "3" => RoomVersionId(InnerRoomVersionId::Version3), + "4" => RoomVersionId(InnerRoomVersionId::Version4), + "5" => RoomVersionId(InnerRoomVersionId::Version5), + custom => { + if custom.is_empty() { + return Err(Error::MinimumLengthNotSatisfied); + } else if custom.chars().count() > MAX_CODE_POINTS { + return Err(Error::MaximumLengthExceeded); + } else { + RoomVersionId(InnerRoomVersionId::Custom(room_version_id.into())) + } + } + }; + + Ok(version) +} + +impl<'a> TryFrom<&'a str> for RoomVersionId<&'a str> { + type Error = crate::error::Error; + + fn try_from(s: &'a str) -> Result { + try_from(s) + } +} + +impl TryFrom<&str> for RoomVersionId> { + type Error = crate::error::Error; + + fn try_from(s: &str) -> Result { + try_from(s) + } +} + +impl TryFrom for RoomVersionId> { + type Error = crate::error::Error; + + fn try_from(s: String) -> Result { + try_from(s) + } +} + +impl> PartialEq<&str> for RoomVersionId { + fn eq(&self, other: &&str) -> bool { + self.as_ref() == *other + } +} + +impl> PartialEq> for &str { + fn eq(&self, other: &RoomVersionId) -> bool { + *self == other.as_ref() + } +} + +impl> PartialEq for RoomVersionId { + fn eq(&self, other: &String) -> bool { + self.as_ref() == other + } +} + +impl> PartialEq> for String { + fn eq(&self, other: &RoomVersionId) -> bool { + self == other.as_ref() + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_str, to_string}; + + use crate::error::Error; + + type RoomVersionId = super::RoomVersionId>; + + #[test] + fn valid_version_1_room_version_id() { + assert_eq!( + RoomVersionId::try_from("1") + .expect("Failed to create RoomVersionId.") + .as_ref(), + "1" + ); + } + + #[test] + fn valid_version_2_room_version_id() { + assert_eq!( + RoomVersionId::try_from("2") + .expect("Failed to create RoomVersionId.") + .as_ref(), + "2" + ); + } + + #[test] + fn valid_version_3_room_version_id() { + assert_eq!( + RoomVersionId::try_from("3") + .expect("Failed to create RoomVersionId.") + .as_ref(), + "3" + ); + } + + #[test] + fn valid_version_4_room_version_id() { + assert_eq!( + RoomVersionId::try_from("4") + .expect("Failed to create RoomVersionId.") + .as_ref(), + "4" + ); + } + + #[test] + fn valid_version_5_room_version_id() { + assert_eq!( + RoomVersionId::try_from("5") + .expect("Failed to create RoomVersionId.") + .as_ref(), + "5" + ); + } + + #[test] + fn valid_custom_room_version_id() { + assert_eq!( + RoomVersionId::try_from("io.ruma.1") + .expect("Failed to create RoomVersionId.") + .as_ref(), + "io.ruma.1" + ); + } + + #[test] + fn empty_room_version_id() { + assert_eq!( + RoomVersionId::try_from(""), + Err(Error::MinimumLengthNotSatisfied) + ); + } + + #[test] + fn over_max_code_point_room_version_id() { + assert_eq!( + RoomVersionId::try_from("0123456789012345678901234567890123456789"), + Err(Error::MaximumLengthExceeded) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_official_room_id() { + assert_eq!( + to_string(&RoomVersionId::try_from("1").expect("Failed to create RoomVersionId.")) + .expect("Failed to convert RoomVersionId to JSON."), + r#""1""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_official_room_id() { + let deserialized = + from_str::(r#""1""#).expect("Failed to convert RoomVersionId to JSON."); + + assert!(deserialized.is_version_1()); + assert!(deserialized.is_official()); + + assert_eq!( + deserialized, + RoomVersionId::try_from("1").expect("Failed to create RoomVersionId.") + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_custom_room_id() { + assert_eq!( + to_string( + &RoomVersionId::try_from("io.ruma.1").expect("Failed to create RoomVersionId.") + ) + .expect("Failed to convert RoomVersionId to JSON."), + r#""io.ruma.1""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_custom_room_id() { + let deserialized = from_str::(r#""io.ruma.1""#) + .expect("Failed to convert RoomVersionId to JSON."); + + assert!(deserialized.is_custom()); + + assert_eq!( + deserialized, + RoomVersionId::try_from("io.ruma.1").expect("Failed to create RoomVersionId.") + ); + } + + #[test] + fn constructors() { + assert!(RoomVersionId::version_1().is_version_1()); + assert!(RoomVersionId::version_2().is_version_2()); + assert!(RoomVersionId::version_3().is_version_3()); + assert!(RoomVersionId::version_4().is_version_4()); + assert!(RoomVersionId::version_5().is_version_5()); + assert!(RoomVersionId::custom("foo".into()).is_custom()); + } + + #[test] + #[allow(clippy::cognitive_complexity)] + fn predicate_methods() { + let version_1 = RoomVersionId::try_from("1").expect("Failed to create RoomVersionId."); + let version_2 = RoomVersionId::try_from("2").expect("Failed to create RoomVersionId."); + let version_3 = RoomVersionId::try_from("3").expect("Failed to create RoomVersionId."); + let version_4 = RoomVersionId::try_from("4").expect("Failed to create RoomVersionId."); + let version_5 = RoomVersionId::try_from("5").expect("Failed to create RoomVersionId."); + let custom = RoomVersionId::try_from("io.ruma.1").expect("Failed to create RoomVersionId."); + + assert!(version_1.is_version_1()); + assert!(version_2.is_version_2()); + assert!(version_3.is_version_3()); + assert!(version_4.is_version_4()); + assert!(version_5.is_version_5()); + + assert!(!version_1.is_version_2()); + assert!(!version_1.is_version_3()); + assert!(!version_1.is_version_4()); + assert!(!version_1.is_version_5()); + + assert!(version_1.is_official()); + assert!(version_2.is_official()); + assert!(version_3.is_official()); + assert!(version_4.is_official()); + assert!(version_5.is_official()); + + assert!(!version_1.is_custom()); + assert!(!version_2.is_custom()); + assert!(!version_3.is_custom()); + assert!(!version_4.is_custom()); + assert!(!version_5.is_custom()); + + assert!(custom.is_custom()); + assert!(!custom.is_official()); + assert!(!custom.is_version_1()); + assert!(!custom.is_version_2()); + assert!(!custom.is_version_3()); + assert!(!custom.is_version_4()); + assert!(!custom.is_version_5()); + } +} diff --git a/ruma-identifiers/src/server_key_id.rs b/ruma-identifiers/src/server_key_id.rs new file mode 100644 index 00000000..336a052b --- /dev/null +++ b/ruma-identifiers/src/server_key_id.rs @@ -0,0 +1,125 @@ +//! Identifiers for homeserver signing keys used for federation. + +use std::{num::NonZeroU8, str::FromStr}; + +use crate::{error::Error, key_algorithms::ServerKeyAlgorithm}; + +/// Key identifiers used for homeserver signing keys. +#[derive(Clone, Debug)] +pub struct ServerKeyId { + full_id: T, + colon_idx: NonZeroU8, +} + +impl ServerKeyId { + /// Returns key algorithm of the server key ID. + pub fn algorithm(&self) -> ServerKeyAlgorithm + where + T: AsRef, + { + ServerKeyAlgorithm::from_str(&self.full_id.as_ref()[..self.colon_idx.get() as usize]) + .unwrap() + } + + /// Returns the version of the server key ID. + pub fn version(&self) -> &str + where + T: AsRef, + { + &self.full_id.as_ref()[self.colon_idx.get() as usize + 1..] + } +} + +fn try_from(key_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let key_str = key_id.as_ref(); + let colon_idx = + NonZeroU8::new(key_str.find(':').ok_or(Error::MissingServerKeyDelimiter)? as u8) + .ok_or(Error::UnknownKeyAlgorithm)?; + + validate_server_key_algorithm(&key_str[..colon_idx.get() as usize])?; + + validate_version(&key_str[colon_idx.get() as usize + 1..])?; + + Ok(ServerKeyId { + full_id: key_id.into(), + colon_idx, + }) +} + +common_impls!(ServerKeyId, try_from, "Key ID with algorithm and version"); + +fn validate_version(version: &str) -> Result<(), Error> { + if version.is_empty() { + return Err(Error::MinimumLengthNotSatisfied); + } else if !version.chars().all(|c| c.is_alphanumeric() || c == '_') { + return Err(Error::InvalidCharacters); + } + + Ok(()) +} + +fn validate_server_key_algorithm(algorithm: &str) -> Result<(), Error> { + match ServerKeyAlgorithm::from_str(algorithm) { + Ok(_) => Ok(()), + Err(_) => Err(Error::UnknownKeyAlgorithm), + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::ServerKeyId; + use crate::error::Error; + + #[cfg(feature = "serde")] + use crate::key_algorithms::ServerKeyAlgorithm; + + #[cfg(feature = "serde")] + #[test] + fn deserialize_id() { + let server_key_id: ServerKeyId<_> = from_json_value(json!("ed25519:Abc_1")).unwrap(); + assert_eq!(server_key_id.algorithm(), ServerKeyAlgorithm::Ed25519); + assert_eq!(server_key_id.version(), "Abc_1"); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_id() { + let server_key_id: ServerKeyId<&str> = ServerKeyId::try_from("ed25519:abc123").unwrap(); + assert_eq!( + to_json_value(&server_key_id).unwrap(), + json!("ed25519:abc123") + ); + } + + #[test] + fn invalid_version_characters() { + assert_eq!( + ServerKeyId::<&str>::try_from("ed25519:Abc-1").unwrap_err(), + Error::InvalidCharacters, + ); + } + + #[test] + fn invalid_key_algorithm() { + assert_eq!( + ServerKeyId::<&str>::try_from("signed_curve25519:Abc-1").unwrap_err(), + Error::UnknownKeyAlgorithm, + ); + } + + #[test] + fn missing_delimiter() { + assert_eq!( + ServerKeyId::<&str>::try_from("ed25519|Abc_1").unwrap_err(), + Error::MissingServerKeyDelimiter, + ); + } +} diff --git a/ruma-identifiers/src/server_name.rs b/ruma-identifiers/src/server_name.rs new file mode 100644 index 00000000..65a75596 --- /dev/null +++ b/ruma-identifiers/src/server_name.rs @@ -0,0 +1,105 @@ +/// Check whether a given string is a valid server name according to [the specification][]. +/// +/// [the specification]: https://matrix.org/docs/spec/appendices#server-name +pub fn is_valid_server_name(name: &str) -> bool { + use std::net::Ipv6Addr; + + if name.is_empty() { + return false; + } + + let end_of_host = if name.starts_with('[') { + let end_of_ipv6 = match name.find(']') { + Some(idx) => idx, + None => return false, + }; + + if name[1..end_of_ipv6].parse::().is_err() { + return false; + } + + end_of_ipv6 + 1 + } else { + let end_of_host = name.find(':').unwrap_or_else(|| name.len()); + + if name[..end_of_host] + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.')) + { + return false; + } + + end_of_host + }; + + if name.len() == end_of_host { + true + } else if name.as_bytes()[end_of_host] != b':' { + // hostname is followed by something other than ":port" + false + } else { + // are the remaining characters after ':' a valid port? + name[end_of_host + 1..].parse::().is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::is_valid_server_name; + + #[test] + fn ipv4_host() { + assert!(is_valid_server_name("127.0.0.1")); + } + + #[test] + fn ipv4_host_and_port() { + assert!(is_valid_server_name("1.1.1.1:12000")); + } + + #[test] + fn ipv6() { + assert!(is_valid_server_name("[::1]")); + } + + #[test] + fn ipv6_with_port() { + assert!(is_valid_server_name("[1234:5678::abcd]:5678")); + } + + #[test] + fn dns_name() { + assert!(is_valid_server_name("example.com")); + } + + #[test] + fn dns_name_with_port() { + assert!(is_valid_server_name("ruma.io:8080")); + } + + #[test] + fn empty_string() { + assert!(!is_valid_server_name("")); + } + + #[test] + fn invalid_ipv6() { + assert!(!is_valid_server_name("[test::1]")); + } + + #[test] + fn ipv4_with_invalid_port() { + assert!(!is_valid_server_name("127.0.0.1:")); + } + + #[test] + fn ipv6_with_invalid_port() { + assert!(!is_valid_server_name("[fe80::1]:100000")); + assert!(!is_valid_server_name("[fe80::1]!")); + } + + #[test] + fn dns_name_with_invalid_port() { + assert!(!is_valid_server_name("matrix.org:hello")); + } +} diff --git a/ruma-identifiers/src/user_id.rs b/ruma-identifiers/src/user_id.rs new file mode 100644 index 00000000..2e8cb77c --- /dev/null +++ b/ruma-identifiers/src/user_id.rs @@ -0,0 +1,340 @@ +//! Matrix user identifiers. + +use std::num::NonZeroU8; + +use crate::{error::Error, is_valid_server_name, parse_id}; + +/// A Matrix user ID. +/// +/// A `UserId` is generated randomly or converted from a string slice, and can be converted back +/// into a string as needed. +/// +/// It is discouraged to use this type directly – instead use one of the aliases (`UserId` and +/// `UserIdRef`) in the crate root. +/// +/// ``` +/// # use std::convert::TryFrom; +/// # use ruma_identifiers::UserId; +/// assert_eq!( +/// UserId::try_from("@carl:example.com").unwrap().as_ref(), +/// "@carl:example.com" +/// ); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct UserId { + full_id: T, + colon_idx: NonZeroU8, + /// Whether this user id is a historical one. + /// + /// A historical user id is one that is not legal per the regular user id rules, but was + /// accepted by previous versions of the spec and thus has to be supported because users with + /// these kinds of ids still exist. + is_historical: bool, +} + +impl UserId { + /// Attempts to generate a `UserId` for the given origin server with a localpart consisting of + /// 12 random ASCII characters. + /// + /// Fails if the given homeserver cannot be parsed as a valid host. + #[cfg(feature = "rand")] + #[cfg_attr(docsrs, doc(cfg(feature = "rand")))] + pub fn new(server_name: &str) -> Result + where + String: Into, + { + use crate::generate_localpart; + + if !is_valid_server_name(server_name) { + return Err(Error::InvalidServerName); + } + let full_id = format!("@{}:{}", generate_localpart(12).to_lowercase(), server_name).into(); + + Ok(Self { + full_id, + colon_idx: NonZeroU8::new(13).unwrap(), + is_historical: false, + }) + } + + /// Attempts to complete a user ID, by adding the colon + server name and `@` prefix, if not + /// present already. + /// + /// This is a convenience function for the login API, where a user can supply either their full + /// user ID or just the localpart. It only supports a valid user ID or a valid user ID + /// localpart, not the localpart plus the `@` prefix, or the localpart plus server name without + /// the `@` prefix. + pub fn parse_with_server_name( + id: impl AsRef + Into, + server_name: &str, + ) -> Result + where + String: Into, + { + let id_str = id.as_ref(); + + if id_str.starts_with('@') { + try_from(id) + } else { + let is_fully_conforming = localpart_is_fully_comforming(id_str)?; + if !is_valid_server_name(server_name) { + return Err(Error::InvalidServerName); + } + + Ok(Self { + full_id: format!("@{}:{}", id_str, server_name).into(), + colon_idx: NonZeroU8::new(id_str.len() as u8 + 1).unwrap(), + is_historical: !is_fully_conforming, + }) + } + } + + /// Returns the user's localpart. + pub fn localpart(&self) -> &str + where + T: AsRef, + { + &self.full_id.as_ref()[1..self.colon_idx.get() as usize] + } + + /// Returns the server name of the user ID. + pub fn server_name(&self) -> &str + where + T: AsRef, + { + &self.full_id.as_ref()[self.colon_idx.get() as usize + 1..] + } + + /// Whether this user ID is a historical one, i.e. one that doesn't conform to the latest + /// specification of the user ID grammar but is still accepted because it was previously + /// allowed. + pub fn is_historical(&self) -> bool { + self.is_historical + } +} + +/// Attempts to create a new Matrix user ID from a string representation. +/// +/// The string must include the leading @ sigil, the localpart, a literal colon, and a server name. +fn try_from(user_id: S) -> Result, Error> +where + S: AsRef + Into, +{ + let colon_idx = parse_id(user_id.as_ref(), &['@'])?; + let localpart = &user_id.as_ref()[1..colon_idx.get() as usize]; + + let is_historical = localpart_is_fully_comforming(localpart)?; + + Ok(UserId { + full_id: user_id.into(), + colon_idx, + is_historical: !is_historical, + }) +} + +common_impls!(UserId, try_from, "a Matrix user ID"); + +/// Check whether the given user id localpart is valid and fully conforming +/// +/// Returns an `Err` for invalid user ID localparts, `Ok(false)` for historical user ID localparts +/// and `Ok(true)` for fully conforming user ID localparts. +pub fn localpart_is_fully_comforming(localpart: &str) -> Result { + if localpart.is_empty() { + return Err(Error::InvalidLocalPart); + } + + // See https://matrix.org/docs/spec/appendices#user-identifiers + let is_fully_conforming = localpart.bytes().all(|b| match b { + b'0'..=b'9' | b'a'..=b'z' | b'-' | b'.' | b'=' | b'_' | b'/' => true, + _ => false, + }); + + // If it's not fully conforming, check if it contains characters that are also disallowed + // for historical user IDs. If there are, return an error. + // See https://matrix.org/docs/spec/appendices#historical-user-ids + if !is_fully_conforming && localpart.bytes().any(|b| b < 0x21 || b == b':' || b > 0x7E) { + Err(Error::InvalidCharacters) + } else { + Ok(is_fully_conforming) + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + #[cfg(feature = "serde")] + use serde_json::{from_str, to_string}; + + use crate::error::Error; + + type UserId = super::UserId>; + + #[test] + fn valid_user_id_from_str() { + let user_id = UserId::try_from("@carl:example.com").expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@carl:example.com"); + assert_eq!(user_id.localpart(), "carl"); + assert_eq!(user_id.server_name(), "example.com"); + assert!(!user_id.is_historical()); + } + + #[test] + fn parse_valid_user_id() { + let user_id = UserId::parse_with_server_name("@carl:example.com", "example.com") + .expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@carl:example.com"); + assert_eq!(user_id.localpart(), "carl"); + assert_eq!(user_id.server_name(), "example.com"); + assert!(!user_id.is_historical()); + } + + #[test] + fn parse_valid_user_id_parts() { + let user_id = UserId::parse_with_server_name("carl", "example.com") + .expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@carl:example.com"); + assert_eq!(user_id.localpart(), "carl"); + assert_eq!(user_id.server_name(), "example.com"); + assert!(!user_id.is_historical()); + } + + #[test] + fn valid_historical_user_id() { + let user_id = UserId::try_from("@a%b[irc]:example.com").expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@a%b[irc]:example.com"); + assert_eq!(user_id.localpart(), "a%b[irc]"); + assert_eq!(user_id.server_name(), "example.com"); + assert!(user_id.is_historical()); + } + + #[test] + fn parse_valid_historical_user_id() { + let user_id = UserId::parse_with_server_name("@a%b[irc]:example.com", "example.com") + .expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@a%b[irc]:example.com"); + assert_eq!(user_id.localpart(), "a%b[irc]"); + assert_eq!(user_id.server_name(), "example.com"); + assert!(user_id.is_historical()); + } + + #[test] + fn parse_valid_historical_user_id_parts() { + let user_id = UserId::parse_with_server_name("a%b[irc]", "example.com") + .expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@a%b[irc]:example.com"); + assert_eq!(user_id.localpart(), "a%b[irc]"); + assert_eq!(user_id.server_name(), "example.com"); + assert!(user_id.is_historical()); + } + + #[test] + fn uppercase_user_id() { + let user_id = UserId::try_from("@CARL:example.com").expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@CARL:example.com"); + assert!(user_id.is_historical()); + } + + #[cfg(feature = "rand")] + #[test] + fn generate_random_valid_user_id() { + let user_id = UserId::new("example.com").expect("Failed to generate UserId."); + assert_eq!(user_id.localpart().len(), 12); + assert_eq!(user_id.server_name(), "example.com"); + + let id_str: &str = user_id.as_ref(); + + assert!(id_str.starts_with('@')); + assert_eq!(id_str.len(), 25); + } + + #[cfg(feature = "rand")] + #[test] + fn generate_random_invalid_user_id() { + assert!(UserId::new("").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serialize_valid_user_id() { + assert_eq!( + to_string(&UserId::try_from("@carl:example.com").expect("Failed to create UserId.")) + .expect("Failed to convert UserId to JSON."), + r#""@carl:example.com""# + ); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialize_valid_user_id() { + assert_eq!( + from_str::(r#""@carl:example.com""#).expect("Failed to convert JSON to UserId"), + UserId::try_from("@carl:example.com").expect("Failed to create UserId.") + ); + } + + #[test] + fn valid_user_id_with_explicit_standard_port() { + assert_eq!( + UserId::try_from("@carl:example.com:443") + .expect("Failed to create UserId.") + .as_ref(), + "@carl:example.com:443" + ); + } + + #[test] + fn valid_user_id_with_non_standard_port() { + let user_id = UserId::try_from("@carl:example.com:5000").expect("Failed to create UserId."); + assert_eq!(user_id.as_ref(), "@carl:example.com:5000"); + assert!(!user_id.is_historical()); + } + + #[test] + fn invalid_characters_in_user_id_localpart() { + assert_eq!( + UserId::try_from("@te\nst:example.com").unwrap_err(), + Error::InvalidCharacters + ); + } + + #[test] + fn missing_user_id_sigil() { + assert_eq!( + UserId::try_from("carl:example.com").unwrap_err(), + Error::MissingSigil + ); + } + + #[test] + fn missing_localpart() { + assert_eq!( + UserId::try_from("@:example.com").unwrap_err(), + Error::InvalidLocalPart + ); + } + + #[test] + fn missing_user_id_delimiter() { + assert_eq!( + UserId::try_from("@carl").unwrap_err(), + Error::MissingDelimiter + ); + } + + #[test] + fn invalid_user_id_host() { + assert_eq!( + UserId::try_from("@carl:/").unwrap_err(), + Error::InvalidServerName + ); + } + + #[test] + fn invalid_user_id_port() { + assert_eq!( + UserId::try_from("@carl:example.com:notaport").unwrap_err(), + Error::InvalidServerName + ); + } +}