Skip to main content

rivetkit_shared_types/
endpoint.rs

1//! Rivet endpoint URL handling shared by the RivetKit server and client crates.
2//! The TypeScript twins are `tryParseEndpoint` in `utils/endpoint-parser.ts`
3//! and `isLocalEngineEndpoint` in `common/engine.ts`; keep the three in step so
4//! a Rust process resolves the same connection as a TypeScript one.
5
6use std::fmt;
7use std::net::IpAddr;
8
9use url::Url;
10
11/// An endpoint with URL auth credentials split out and stripped.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ParsedEndpoint {
14	/// The URL-normalized endpoint without credentials.
15	pub endpoint: String,
16	pub namespace: Option<String>,
17	pub token: Option<String>,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum EndpointParseError {
22	InvalidUrl {
23		endpoint: String,
24	},
25	QueryString,
26	Fragment,
27	TokenWithoutNamespace,
28	/// The raw credential is left out because it may be a token.
29	InvalidCredentialEncoding,
30	CredentialsUnsupported,
31}
32
33impl fmt::Display for EndpointParseError {
34	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35		match self {
36			Self::InvalidUrl { endpoint } => write!(f, "invalid URL: {endpoint}"),
37			Self::QueryString => write!(f, "endpoint cannot contain a query string"),
38			Self::Fragment => write!(f, "endpoint cannot contain a fragment"),
39			Self::TokenWithoutNamespace => {
40				write!(f, "endpoint cannot have a token without a namespace")
41			}
42			Self::InvalidCredentialEncoding => {
43				write!(f, "invalid percent-encoding in endpoint URL auth")
44			}
45			Self::CredentialsUnsupported => {
46				write!(f, "endpoint URL does not support credentials")
47			}
48		}
49	}
50}
51
52impl std::error::Error for EndpointParseError {}
53
54/// Parses an endpoint that may carry `https://namespace:token@host` URL auth.
55/// The returned endpoint is the URL-normalized string with any credentials
56/// stripped. Errors on invalid URLs, query strings, fragments, and a token
57/// without a namespace.
58pub fn parse_endpoint_auth(endpoint: &str) -> Result<ParsedEndpoint, EndpointParseError> {
59	let mut url = Url::parse(endpoint).map_err(|_| EndpointParseError::InvalidUrl {
60		endpoint: endpoint.to_owned(),
61	})?;
62	if url.query().is_some() {
63		return Err(EndpointParseError::QueryString);
64	}
65	if url.fragment().is_some() {
66		return Err(EndpointParseError::Fragment);
67	}
68
69	let namespace = if url.username().is_empty() {
70		None
71	} else {
72		Some(percent_decode(url.username())?)
73	};
74	let token = match url.password() {
75		None | Some("") => None,
76		Some(password) => Some(percent_decode(password)?),
77	};
78	if token.is_some() && namespace.is_none() {
79		return Err(EndpointParseError::TokenWithoutNamespace);
80	}
81
82	// Only strip credentials when some were parsed. URLs that carry auth
83	// always have an authority component, so clearing cannot fail there,
84	// while opaque-scheme URLs without auth would reject the setters.
85	if namespace.is_some() || token.is_some() {
86		if url.set_username("").is_err() || url.set_password(None).is_err() {
87			return Err(EndpointParseError::CredentialsUnsupported);
88		}
89	}
90
91	Ok(ParsedEndpoint {
92		endpoint: url.to_string(),
93		namespace,
94		token,
95	})
96}
97
98/// Whether the endpoint points at an engine on this machine. Local engines
99/// started by RivetKit use the `default` token, so callers may fall back to it
100/// only when this returns true.
101pub fn is_local_engine_endpoint(endpoint: &str) -> bool {
102	let Ok(url) = Url::parse(endpoint) else {
103		return false;
104	};
105	let Some(host) = url.host_str() else {
106		return false;
107	};
108
109	if host.eq_ignore_ascii_case("localhost") {
110		return true;
111	}
112
113	let ip_literal = host.trim_start_matches('[').trim_end_matches(']');
114	match ip_literal.parse::<IpAddr>() {
115		Ok(ip) => ip.is_loopback() || ip.is_unspecified(),
116		Err(_) => false,
117	}
118}
119
120fn percent_decode(value: &str) -> Result<String, EndpointParseError> {
121	let bytes = value.as_bytes();
122	let mut out = Vec::with_capacity(bytes.len());
123	let mut i = 0;
124	while i < bytes.len() {
125		if bytes[i] == b'%' {
126			if i + 2 >= bytes.len() {
127				return Err(EndpointParseError::InvalidCredentialEncoding);
128			}
129			let hi =
130				hex_value(bytes[i + 1]).ok_or(EndpointParseError::InvalidCredentialEncoding)?;
131			let lo =
132				hex_value(bytes[i + 2]).ok_or(EndpointParseError::InvalidCredentialEncoding)?;
133			out.push((hi << 4) | lo);
134			i += 3;
135		} else {
136			out.push(bytes[i]);
137			i += 1;
138		}
139	}
140	String::from_utf8(out).map_err(|_| EndpointParseError::InvalidCredentialEncoding)
141}
142
143fn hex_value(byte: u8) -> Option<u8> {
144	match byte {
145		b'0'..=b'9' => Some(byte - b'0'),
146		b'a'..=b'f' => Some(byte - b'a' + 10),
147		b'A'..=b'F' => Some(byte - b'A' + 10),
148		_ => None,
149	}
150}