use core::fmt;
use core::str::FromStr;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MessageEndpoint(String);
impl MessageEndpoint {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn path(&self) -> &str {
match self.0.split_once('?') {
Some((path, _query)) => path,
None => &self.0,
}
}
#[must_use]
pub fn query(&self) -> Option<&str> {
self.0.split_once('?').map(|(_path, query)| query)
}
pub fn bare_path(value: &str) -> Result<Self, MessageEndpointError> {
let endpoint = value.parse::<Self>()?;
if value.contains(['?', '#']) {
return Err(MessageEndpointError::QueryOrFragment);
}
Ok(endpoint)
}
#[must_use]
pub fn with_query_parameter(&self, key: &str, value: &str) -> Self {
let separator = if self.query().is_some() { '&' } else { '?' };
Self(format!("{}{separator}{key}={value}", self.0))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum MessageEndpointError {
#[error("a message endpoint may not be empty")]
Empty,
#[error(
"a message endpoint may not be an absolute URI; it must stay on the origin the client dialled"
)]
Absolute,
#[error(
"a message endpoint may not be protocol-relative; it must stay on the origin the client dialled"
)]
ProtocolRelative,
#[error("a message endpoint may not contain a control character or a space")]
IllegalCharacter,
#[error("a configured message path must not carry a query or a fragment")]
QueryOrFragment,
#[error("a message endpoint must be an absolute path starting with `/`")]
NotAbsolutePath,
}
impl FromStr for MessageEndpoint {
type Err = MessageEndpointError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() || value == "?" || value.starts_with('?') {
return Err(MessageEndpointError::Empty);
}
if value.starts_with("//") {
return Err(MessageEndpointError::ProtocolRelative);
}
if has_scheme(value) {
return Err(MessageEndpointError::Absolute);
}
if value
.chars()
.any(|c| c.is_control() || c == ' ' || c == '\u{7f}')
{
return Err(MessageEndpointError::IllegalCharacter);
}
if !value.starts_with('/') {
return Err(MessageEndpointError::NotAbsolutePath);
}
Ok(Self(value.to_string()))
}
}
fn has_scheme(value: &str) -> bool {
let Some(colon) = value.find(':') else {
return false;
};
let scheme = value.split_at(colon).0;
let mut chars = scheme.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
impl fmt::Display for MessageEndpoint {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_configured_path_may_not_already_carry_a_query_or_a_fragment() {
use MessageEndpointError as E;
let cases = [
("/messages", None),
("/messages?sessionId=1", Some(E::QueryOrFragment)),
("/messages#a", Some(E::QueryOrFragment)),
("/messages#a?b=c", Some(E::QueryOrFragment)),
("?sessionId=1", Some(E::Empty)),
("https://elsewhere.example/x", Some(E::Absolute)),
("//elsewhere.example/x", Some(E::ProtocolRelative)),
("messages", Some(E::NotAbsolutePath)),
("/mess ages", Some(E::IllegalCharacter)),
];
for (value, wanted) in cases {
assert_eq!(
MessageEndpoint::bare_path(value).err(),
wanted,
"bare_path({value:?})"
);
}
}
#[test]
fn appending_extends_an_existing_query_rather_than_replacing_it() -> Result<(), TestError> {
let endpoint = MessageEndpoint::from_str("/messages?tenant=a")?;
let with_session = endpoint.with_query_parameter("sessionId", "0000000000000001");
assert_eq!(
with_session.as_str(),
"/messages?tenant=a&sessionId=0000000000000001"
);
assert_eq!(with_session.path(), "/messages");
assert_eq!(
with_session.query(),
Some("tenant=a&sessionId=0000000000000001")
);
Ok(())
}
#[test]
fn appending_to_a_bare_path_introduces_the_query() -> Result<(), TestError> {
let endpoint = MessageEndpoint::from_str("/messages")?;
let with_session = endpoint.with_query_parameter("sessionId", "0000000000000001");
assert_eq!(
with_session.as_str(),
"/messages?sessionId=0000000000000001"
);
assert_eq!(with_session.path(), "/messages");
assert_eq!(with_session.query(), Some("sessionId=0000000000000001"));
Ok(())
}
use super::{MessageEndpoint, MessageEndpointError};
use core::str::FromStr;
type TestError = Box<dyn std::error::Error>;
fn accepts(value: &str, expected: &str) -> Result<(), TestError> {
let endpoint = MessageEndpoint::from_str(value)?;
if endpoint.as_str() != expected {
return Err(format!("{value:?} read back as {:?}", endpoint.as_str()).into());
}
Ok(())
}
fn refuses(value: &str, expected: &MessageEndpointError) -> Result<(), TestError> {
match MessageEndpoint::from_str(value) {
Err(error) if &error == expected => Ok(()),
Err(error) => {
Err(format!("{value:?} refused as {error:?}, wanted {expected:?}").into())
}
Ok(endpoint) => Err(format!(
"{value:?} was accepted as {:?}, which can leave the origin",
endpoint.as_str()
)
.into()),
}
}
#[test]
fn the_reference_shape_parses() -> Result<(), TestError> {
accepts(
"/messages?sessionId=0a1b2c3d",
"/messages?sessionId=0a1b2c3d",
)
}
#[test]
fn a_plain_path_parses() -> Result<(), TestError> {
accepts("/messages", "/messages")
}
#[test]
fn a_path_and_query_are_kept_together() -> Result<(), TestError> {
let endpoint = MessageEndpoint::from_str("/messages?sessionId=x&y=z")?;
if endpoint.path() != "/messages" {
return Err(format!("path read as {:?}", endpoint.path()).into());
}
if endpoint.query() != Some("sessionId=x&y=z") {
return Err(format!("query read as {:?}", endpoint.query()).into());
}
if endpoint.as_str() != "/messages?sessionId=x&y=z" {
return Err("the target was rebuilt rather than kept".into());
}
Ok(())
}
#[test]
fn an_absolute_url_is_refused() -> Result<(), TestError> {
refuses(
"http://169.254.169.254/latest/meta-data/",
&MessageEndpointError::Absolute,
)
}
#[test]
fn a_schemed_url_without_slashes_is_refused() -> Result<(), TestError> {
refuses("data:text/plain,hello", &MessageEndpointError::Absolute)
}
#[test]
fn a_protocol_relative_url_is_refused() -> Result<(), TestError> {
refuses(
"//evil.example/steal",
&MessageEndpointError::ProtocolRelative,
)
}
#[test]
fn a_control_character_is_refused() -> Result<(), TestError> {
refuses(
"/messages\r\nX-Injected: 1",
&MessageEndpointError::IllegalCharacter,
)
}
#[test]
fn a_space_is_refused() -> Result<(), TestError> {
refuses(
"/messages and more",
&MessageEndpointError::IllegalCharacter,
)
}
#[test]
fn a_colon_that_is_not_a_scheme_is_kept() -> Result<(), TestError> {
accepts(
"/messages?sessionId=2024-11-05T00:00:00Z",
"/messages?sessionId=2024-11-05T00:00:00Z",
)
}
#[test]
fn a_scheme_may_not_start_with_a_digit() -> Result<(), TestError> {
refuses("9:foo", &MessageEndpointError::NotAbsolutePath)
}
#[test]
fn a_scheme_may_not_start_with_a_sign_or_a_dot() -> Result<(), TestError> {
for target in ["-x:foo", "+x:foo", ".x:foo"] {
refuses(target, &MessageEndpointError::NotAbsolutePath)?;
}
Ok(())
}
#[test]
fn a_relative_path_is_refused() -> Result<(), TestError> {
refuses(
"messages?sessionId=x",
&MessageEndpointError::NotAbsolutePath,
)
}
#[test]
fn an_empty_target_is_refused() -> Result<(), TestError> {
refuses("", &MessageEndpointError::Empty)
}
#[test]
fn a_bare_query_is_refused() -> Result<(), TestError> {
refuses("?sessionId=x", &MessageEndpointError::Empty)
}
}