diff --git a/Cargo.toml b/Cargo.toml index f423f89..dfbe0ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["rossweisse"] [package] autotests = false name = "windmark" -version = "0.8.0" +version = "0.9.0" authors = ["Fuwn "] edition = "2021" description = "An elegant and highly performant async Gemini server framework" @@ -56,6 +56,7 @@ language-tags = "0.3.2" tree_magic_mini = { version = "3.2.2", optional = true } # MIME [dev-dependencies] +percent-encoding = "2.3.2" pretty_env_logger = "0.5.0" rossweisse = { version = "0.0.4", path = "./rossweisse" } diff --git a/README.md b/README.md index ddd36e6..d83bf29 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,12 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r # Cargo.toml [dependencies] -windmark = "0.8.0" +windmark = "0.9.0" tokio = { version = "1.26.0", features = ["full"] } # If you would like to use the built-in MIME deduction when `Success`-ing a file # (recommended) -# windmark = { version = "0.8.0", features = ["auto-deduce-mime"] } +# windmark = { version = "0.9.0", features = ["auto-deduce-mime"] } ``` ### Implementing a Windmark Server diff --git a/examples/callbacks.rs b/examples/callbacks.rs index cd5cad9..87989cc 100644 --- a/examples/callbacks.rs +++ b/examples/callbacks.rs @@ -12,7 +12,7 @@ async fn main() -> Result<(), Box> { println!( "accepted connection from {} to {}", context.peer_address.unwrap().ip(), - context.url + context.url.path() ) }) .set_post_route_callback(|context: &HookContext, content: &mut Response| { diff --git a/examples/input.rs b/examples/input.rs index 10add0b..57e2d05 100644 --- a/examples/input.rs +++ b/examples/input.rs @@ -9,14 +9,17 @@ async fn main() -> Result<(), Box> { .set_certificate_file("windmark_public.pem") .mount("/input", |context: RouteContext| { if let Some(name) = context.url.query() { - Response::success(format!("Your name is {}!", name)) + match percent_encoding::percent_decode_str(name).decode_utf8() { + Ok(name) => Response::success(format!("Your name is {name}!")), + Err(_) => Response::bad_request("Input must be valid UTF-8."), + } } else { Response::input("What is your name?") } }) .mount("/sensitive", |context: RouteContext| { - if let Some(password) = context.url.query() { - Response::success(format!("Your password is {}!", password)) + if context.url.query().is_some() { + Response::success("Your input was received.") } else { Response::sensitive_input("What is your password?") } diff --git a/src/context/parameters.rs b/src/context/parameters.rs index 8a5cd4d..583eecd 100644 --- a/src/context/parameters.rs +++ b/src/context/parameters.rs @@ -15,15 +15,21 @@ impl Parameters { parameters: &matchit::Params<'_, '_>, lookup: &str, original: &str, + offsets: &[usize], ) -> Self { Self( parameters .iter() .map(|(name, value)| { - // Matchit borrows each value from the lookup path. ASCII case folding - // preserves byte offsets, so the original path has the same spans. + // Matchit borrows from the lookup path. Percent decoding needs a + // boundary map; ASCII case folding alone preserves byte offsets. let start = value.as_ptr().addr() - lookup.as_ptr().addr(); let end = start + value.len(); + let (start, end) = if offsets.is_empty() { + (start, end) + } else { + (offsets[start], offsets[end]) + }; (name.to_owned(), original[start..end].to_owned()) }) diff --git a/src/response.rs b/src/response.rs index 350e788..b8b506d 100644 --- a/src/response.rs +++ b/src/response.rs @@ -217,7 +217,8 @@ impl Response { } } - /// Return the response's media type override. + /// Return the response's media type override. Explicit character-set and + /// language setters override parameters included in this value. #[must_use] pub fn mime(&self) -> Option<&str> { self.mime.as_deref() } @@ -228,8 +229,8 @@ impl Response { #[must_use] pub fn character_set(&self) -> Option<&str> { self.character_set.as_deref() } - /// Borrow the character set override; set it to `None` to use the router's - /// default. + /// Borrow the character set override; `None` preserves a MIME parameter or, + /// for string responses without one, uses the router's default. pub const fn character_set_mut(&mut self) -> &mut Option { &mut self.character_set } @@ -239,12 +240,16 @@ impl Response { #[must_use] pub fn languages(&self) -> Option<&[String]> { self.languages.as_deref() } - /// Borrow the language override; set it to `None` to use the router's - /// default. + /// Borrow the language override; `None` preserves a MIME parameter or, + /// for string responses without one, uses the router's default. pub const fn languages_mut(&mut self) -> &mut Option> { &mut self.languages } + /// Set the media type and its parameters. Explicit character-set and + /// language overrides take precedence; router defaults fill omitted + /// parameters on string responses. Invalid metadata produces a temporary + /// failure when the server sends the response. pub fn with_mime( &mut self, mime: impl Into + AsRef, @@ -254,6 +259,8 @@ impl Response { self } + /// Declare the payload's character set without transcoding it. String + /// responses require UTF-8; use a binary response for pre-encoded content. pub fn with_character_set( &mut self, character_set: impl Into + AsRef, diff --git a/src/router.rs b/src/router.rs index 75442c0..5d1c955 100644 --- a/src/router.rs +++ b/src/router.rs @@ -397,43 +397,8 @@ fn status_line( let status = content.status(); if matches!(status, 20..=22) { - let mime = content.mime().unwrap_or(if status == 20 { - "text/gemini" - } else { - "application/octet-stream" - }); - let metadata = if status == 20 { - let character_set = - content.character_set().unwrap_or(default_character_set); - let languages = content.languages().map_or_else( - || default_languages.to_owned(), - |languages| languages.join(","), - ); - let mut metadata = - format!("{mime}; charset={}", mime_parameter(character_set)); - - if !languages.is_empty() { - if languages - .split(',') - .any(|language| language_tags::LanguageTag::parse(language).is_err()) - { - return Err("invalid response language tag"); - } - - write!(&mut metadata, "; lang={}", mime_parameter(&languages)) - .expect("writing to a string cannot fail"); - } - - metadata - } else { - mime.to_owned() - }; - - if metadata.chars().any(char::is_control) - || metadata.parse::().is_err() - { - return Err("invalid response media type or parameters"); - } + let metadata = + response_metadata(content, default_character_set, default_languages)?; return Ok(format!("20 {metadata}")); } @@ -468,6 +433,112 @@ fn status_line( } } +fn response_metadata( + content: &Response, + default_character_set: &str, + default_languages: &str, +) -> Result { + let text_payload = content.status() == 20; + let media_type = content.mime().unwrap_or(if text_payload { + "text/gemini" + } else { + "application/octet-stream" + }); + + if !media_type.is_ascii() || media_type.chars().any(char::is_control) { + return Err("invalid response media type or parameters"); + } + + let media_type = media_type + .parse::() + .map_err(|_| "invalid response media type or parameters")?; + + if media_type.type_() == mime::STAR || media_type.subtype() == mime::STAR { + return Err("response media type must not contain a wildcard"); + } + + let mut parameters = Vec::<(String, String)>::new(); + + for (name, value) in media_type.params() { + if parameters + .iter() + .any(|(existing, _)| name == existing.as_str()) + { + return Err("duplicate response media type parameter"); + } + + let mut decoded = String::new(); + let mut characters = value.as_str().chars(); + + while let Some(character) = characters.next() { + decoded.push(if character == '\\' { + characters + .next() + .ok_or("invalid quoted media type parameter")? + } else { + character + }); + } + + parameters.push((name.as_str().to_ascii_lowercase(), decoded)); + } + + let language_override = + content.languages().map(|languages| languages.join(",")); + + for (name, explicit, fallback) in [ + ("charset", content.character_set(), default_character_set), + ("lang", language_override.as_deref(), default_languages), + ] { + let existing = parameters + .iter() + .position(|(parameter, _)| parameter == name); + + if let Some(value) = explicit + .or_else(|| (text_payload && existing.is_none()).then_some(fallback)) + { + if let Some(position) = existing { + parameters.remove(position); + } + + if name != "lang" || !value.is_empty() { + parameters.push((name.to_owned(), value.to_owned())); + } + } + } + + let mut metadata = media_type.essence_str().to_owned(); + + for (name, value) in parameters { + if !value.is_ascii() || value.chars().any(char::is_control) { + return Err("invalid response media type parameter"); + } + + if text_payload && name == "charset" && !value.eq_ignore_ascii_case("utf-8") + { + return Err("string responses require the UTF-8 character set"); + } + + if media_type.essence_str() == "text/gemini" + && name == "lang" + && value + .split(',') + .any(|language| language_tags::LanguageTag::parse(language).is_err()) + { + return Err("invalid response language tag"); + } + + write!(&mut metadata, "; {name}={}", mime_parameter(&value)) + .expect("writing to a string cannot fail"); + } + + metadata + .parse::() + .map_err(|_| "invalid response media type or parameters")?; + + Ok(metadata) +} + fn mime_parameter(value: &str) -> String { if !value.is_empty() && value.bytes().all(|byte| { @@ -600,6 +671,9 @@ impl Router { /// Map routes to URL paths. /// /// This method supports both synchronous and asynchronous handlers. + /// Percent-escape case and escaped unreserved characters are normalised + /// for matching. Captures retain their original spelling; escaped slashes + /// remain distinct from path separators. /// /// # Examples /// @@ -616,7 +690,8 @@ impl Router { /// # Panics /// /// This method panics if the route is invalid or conflicts with a mounted - /// route, including conflicts under case-insensitive matching when enabled. + /// route after URI normalisation, including conflicts under case-insensitive + /// matching when enabled. pub fn mount( &mut self, route: impl Into + AsRef, @@ -626,10 +701,12 @@ impl Router { R: IntoFuture + Send + 'static, ::IntoFuture: Send, { + let route = route.into(); + self .routes .insert( - route.into(), + &route, Arc::new(move |context: RouteContext| handler(context).into_future()), ) .expect("failed to mount route"); @@ -842,7 +919,9 @@ impl Router { } fn create_acceptor(&self) -> Result> { - let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; + let mut builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls())?; + + builder.set_min_proto_version(Some(ssl::SslVersion::TLS1_2))?; if let Some(ref content) = self.certificate_content { let mut certificates = @@ -897,7 +976,7 @@ impl Router { /// /// windmark::router::Router::new().set_ssl_acceptor({ /// let mut builder = - /// ssl::SslAcceptor::mozilla_intermediate(ssl::SslMethod::tls()).unwrap(); + /// ssl::SslAcceptor::mozilla_intermediate_v5(ssl::SslMethod::tls()).unwrap(); /// /// builder /// .set_private_key_file("windmark_private.pem", ssl::SslFiletype::PEM) @@ -1150,9 +1229,12 @@ impl Router { /// Specify a custom character set. /// - /// A character set specified in a [`Response`] overrides this setting. + /// A character set specified in a [`Response`] or its MIME parameters + /// overrides this setting. /// /// The default character set is `"utf-8"`. + /// String responses require UTF-8. To serve another encoding, use a binary + /// response with an explicit character set; its bytes are sent unchanged. /// /// # Examples /// @@ -1170,7 +1252,8 @@ impl Router { /// Specify a custom language. /// - /// Languages specified in a [`Response`] override this setting. + /// Languages specified in a [`Response`] or its MIME parameters override + /// this setting. Router defaults apply only to string responses. /// /// The default language is `"en"`. /// diff --git a/src/router/request.rs b/src/router/request.rs index 175f915..6d1caab 100644 --- a/src/router/request.rs +++ b/src/router/request.rs @@ -25,6 +25,10 @@ pub(super) fn parse_uri(request: &str) -> Result { let url = Url::parse(request).map_err(|error| error.to_string())?; + if !url.username().is_empty() || url.password().is_some() { + return Err("request URI must not contain userinfo".into()); + } + if url.scheme() == "gemini" && url.host_str().is_none_or(str::is_empty) { return Err("Gemini request URI requires a host".into()); } @@ -62,19 +66,85 @@ fn valid_uri_characters(request: &str, allow_fragment: bool) -> bool { } pub(super) fn valid_uri_reference(reference: &str) -> bool { + let first_segment = + reference.split(['/', '?', '#']).next().unwrap_or_default(); + + if let Some((scheme, _)) = first_segment.split_once(':') { + if !scheme + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + || !scheme + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"+-.".contains(&byte)) + { + return false; + } + } + let remainder = reference .split_once(':') .filter(|(prefix, _)| !prefix.contains(['/', '?', '#'])) .map_or(reference, |(_, remainder)| remainder); - let path = remainder.strip_prefix("//").map_or(remainder, |authority| { - &authority[authority.find(['/', '?', '#']).unwrap_or(authority.len())..] - }); + let path = if let Some(authority) = remainder.strip_prefix("//") { + let end = authority.find(['/', '?', '#']).unwrap_or(authority.len()); + + if !valid_authority(&authority[..end]) { + return false; + } + + &authority[end..] + } else { + remainder + }; valid_uri_characters(reference, true) && !path.contains(['[', ']']) && reference.matches('#').count() <= 1 - && Url::parse("gemini://localhost/") - .expect("the base URI is valid") - .join(reference) - .is_ok() +} + +fn valid_authority(authority: &str) -> bool { + let host_and_port = if let Some((userinfo, host)) = authority.split_once('@') + { + if userinfo.contains(['[', ']']) { + return false; + } + + host + } else { + authority + }; + + if let Some(literal) = host_and_port.strip_prefix('[') { + let Some((address, suffix)) = literal.split_once(']') else { + return false; + }; + let valid_address = address.strip_prefix(['v', 'V']).map_or_else( + || address.parse::().is_ok(), + |future| { + future.split_once('.').is_some_and(|(version, address)| { + !version.is_empty() + && version.bytes().all(|byte| byte.is_ascii_hexdigit()) + && !address.is_empty() + && address.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || b"-._~!$&'()*+,;=:".contains(&byte) + }) + }) + }, + ); + + return valid_address + && (suffix.is_empty() + || suffix + .strip_prefix(':') + .is_some_and(|port| port.bytes().all(|byte| byte.is_ascii_digit()))); + } + + let (host, port) = host_and_port + .rsplit_once(':') + .unwrap_or((host_and_port, "")); + + !host.contains(['@', ':', '[', ']']) + && port.bytes().all(|byte| byte.is_ascii_digit()) } diff --git a/src/router/routes.rs b/src/router/routes.rs index 0fc1e78..0f318cd 100644 --- a/src/router/routes.rs +++ b/src/router/routes.rs @@ -1,5 +1,5 @@ use crate::{context::Parameters, handler::RouteResponse}; -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; #[derive(Clone, Default)] pub(super) struct Routes { @@ -16,9 +16,10 @@ pub(super) struct MatchedRoute<'router> { impl Routes { pub fn insert( &mut self, - path: String, + path: &str, handler: Arc, ) -> Result<(), matchit::InsertError> { + let path = normalize_pattern(path); let mut folded = self.folded.clone(); if let Some(routes) = &mut folded { @@ -54,8 +55,10 @@ impl Routes { pub fn disable_case_insensitive(&mut self) { self.folded = None; } pub fn contains(&self, path: &str) -> bool { + let path = normalize_path(path, None); + self.folded.as_ref().map_or_else( - || self.exact.at(path).is_ok(), + || self.exact.at(&path).is_ok(), |routes| routes.at(&path.to_ascii_lowercase()).is_ok(), ) } @@ -64,23 +67,105 @@ impl Routes { &self, path: &str, ) -> Result, matchit::MatchError> { - if let Some(routes) = &self.folded { - let lookup = path.to_ascii_lowercase(); - let matched = routes.at(&lookup)?; - - Ok(MatchedRoute { - value: matched.value, - parameters: Parameters::from_lookup(&matched.params, &lookup, path), - }) - } else { - let matched = self.exact.at(path)?; - - Ok(MatchedRoute { - value: matched.value, - parameters: Parameters::from_parameters(&matched.params), - }) + let mut offsets = Vec::new(); + let mut lookup = normalize_path(path, Some(&mut offsets)); + let routes = self.folded.as_ref().map_or(&self.exact, |routes| { + lookup.to_mut().make_ascii_lowercase(); + + routes + }); + let matched = routes.at(&lookup)?; + + Ok(MatchedRoute { + value: matched.value, + parameters: Parameters::from_lookup( + &matched.params, + &lookup, + path, + &offsets, + ), + }) + } +} + +fn normalize_pattern(path: &str) -> String { + let mut normalized = String::with_capacity(path.len()); + + for segment in path.split_inclusive('/') { + let parameter = segment.find([':', '*']).unwrap_or(segment.len()); + + normalized.push_str(&normalize_path(&segment[..parameter], None)); + normalized.push_str(&segment[parameter..]); + } + + normalized +} + +fn normalize_path<'path>( + path: &'path str, + mut offsets: Option<&mut Vec>, +) -> Cow<'path, str> { + if !path.contains('%') { + return Cow::Borrowed(path); + } + + let bytes = path.as_bytes(); + let mut normalized = Vec::with_capacity(bytes.len()); + let mut position = 0; + + if let Some(offsets) = &mut offsets { + offsets.reserve(bytes.len() + 1); + offsets.push(0); + } + + while position < bytes.len() { + if bytes[position] == b'%' { + if let Some(digits) = bytes.get(position + 1..position + 3) { + if let (Some(high), Some(low)) = ( + (digits[0] as char).to_digit(16), + (digits[1] as char).to_digit(16), + ) { + let decoded = u8::try_from(high * 16 + low) + .expect("two hex digits fit in a byte"); + + if decoded.is_ascii_alphanumeric() || b"-._~".contains(&decoded) { + normalized.push(decoded); + + if let Some(offsets) = &mut offsets { + offsets.push(position + 3); + } + } else { + normalized.extend([ + b'%', + digits[0].to_ascii_uppercase(), + digits[1].to_ascii_uppercase(), + ]); + + if let Some(offsets) = &mut offsets { + offsets.extend([position + 1, position + 2, position + 3]); + } + } + + position += 3; + + continue; + } + } } + + normalized.push(bytes[position]); + + if let Some(offsets) = &mut offsets { + offsets.push(position + 1); + } + + position += 1; } + + Cow::Owned( + String::from_utf8(normalized) + .expect("normalising ASCII escapes preserves UTF-8"), + ) } fn fold_pattern(path: &str) -> String { diff --git a/src/utilities.rs b/src/utilities.rs index 1e8c78b..7b9ab12 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -4,6 +4,10 @@ use std::collections::HashMap; /// Extract decoded query pairs into a map. The last value wins for repeated /// keys; use `Url::query_pairs` directly to retain repeated keys. +/// +/// These are form-style pairs: `+` represents a space. Gemini prompt input is +/// a single percent-encoded value and should be decoded without splitting it +/// into pairs or replacing literal plus signs. #[must_use] pub fn queries_from_url(url: &url::Url) -> HashMap { let mut queries = HashMap::new(); diff --git a/tests/router.rs b/tests/router.rs index a3cb3a3..2556b5c 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -149,11 +149,8 @@ fn success_status_line_uses_the_response_mime() { fn success_status_line_uses_the_response_character_set() { let mut response = Response::success("hi"); - response.with_character_set("iso-8859-1"); - assert_eq!( - line(&response), - "20 text/gemini; charset=iso-8859-1; lang=en" - ); + response.with_character_set("UTF-8"); + assert_eq!(line(&response), "20 text/gemini; charset=UTF-8; lang=en"); } #[test] @@ -808,3 +805,182 @@ fn credentials_load_complete_chains_and_use_the_last_setter() { assert!(router.create_acceptor().is_err()); std::fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn redirects_validate_the_first_path_segment_without_restricting_schemes() { + for target in ["1:foo", ":foo", "a_b:foo", "1:foo/bar"] { + assert!( + status_line(&Response::temporary_redirect(target), "utf-8", "en") + .is_err() + ); + } + + for target in [ + "./1:foo", + "../page", + "/1:foo", + "?query:part", + "#part", + "https://example.com/", + "mailto:user@example.com", + ] { + assert!( + status_line(&Response::temporary_redirect(target), "utf-8", "en").is_ok() + ); + } + + assert!(super::request::parse_uri("http:/user@example.org/").is_err()); +} + +#[test] +fn redirects_validate_authorities_without_normalizing_the_target() { + for target in [ + "gemini://a@b@c/", + "//a@b@c/", + "//user[name]@example.com/", + "//example.com:port/", + "//example.com:12:34/", + "//example[host]/", + "//[::1]suffix/", + "//[::1]:port/", + "//[invalid]/", + "//[v1.]/", + "//[v1.a@b]/", + ] { + assert!( + status_line(&Response::temporary_redirect(target), "utf-8", "en") + .is_err(), + "{target}" + ); + } + + for target in [ + "https://user:password@example.com:443/path", + "//user%40name@example.com/", + "//[::1]:1965/", + "//[v1.example:address]/", + "//example.com:/", + "//example.com:99999/", + "file:///path", + "//example.com/path@part?query@part#fragment@part", + ] { + assert_eq!( + status_line(&Response::temporary_redirect(target), "utf-8", "en") + .unwrap(), + format!("30 {target}") + ); + } +} + +#[test] +fn response_metadata_resolves_overrides_and_rejects_invalid_parameters() { + let mut response = Response::success("body"); + + response.with_mime("text/gemini; charset=iso-8859-1; lang=fr; note=\"a b\""); + response.with_character_set("utf-8"); + response.with_languages(["en", "de"]); + + let metadata = line(&response) + .strip_prefix("20 ") + .unwrap() + .parse::() + .unwrap(); + + assert_eq!( + metadata + .params() + .filter(|(name, _)| *name == mime::CHARSET) + .count(), + 1 + ); + assert_eq!(metadata.get_param("charset").unwrap(), "utf-8"); + assert_eq!(metadata.get_param("lang").unwrap(), "en,de"); + assert_eq!(metadata.get_param("note").unwrap(), "a b"); + response.with_languages(Vec::::new()); + assert!(!line(&response).contains("lang=")); + + for media_type in [ + "text/*", + "*/*", + "text/gemini; lang=bogus_tag", + "text/gemini; lang=\"en,\"", + "text/plain; charset=utf-8; CHARSET=utf-8", + "text/plain; note=\"café\"", + ] { + assert!( + status_line(&Response::binary_success([], media_type), "utf-8", "en") + .is_err(), + "{media_type}" + ); + } +} + +#[test] +fn character_sets_describe_bytes_without_transcoding_them() { + let mut response = Response::success("café"); + + response.with_character_set("iso-8859-1"); + assert!(status_line(&response, "utf-8", "en").is_err()); + response.with_character_set("utf-16"); + assert!(status_line(&response, "utf-8", "en").is_err()); + + let mut response = + Response::binary_success([0x63, 0x61, 0x66, 0xe9], "text/plain"); + + response.with_character_set("iso-8859-1"); + response.with_languages(["fr"]); + assert_eq!( + line(&response), + "20 text/plain; charset=iso-8859-1; lang=fr" + ); + assert_eq!(response.serialize_body("", ""), [0x63, 0x61, 0x66, 0xe9]); +} + +#[test] +fn percent_equivalent_routes_preserve_original_captures_and_reserved_escapes() { + let mut router = super::Router::new(); + + router.mount("/caf%C3%A9/~user/:Name/*Rest", |_| { + Response::success("body") + }); + + for path in [ + "/caf%c3%a9/%7euser/%41lice/Photo%2fone.PNG", + "/caf%C3%A9/~user/%41lice/Photo%2fone.PNG", + ] { + let matched = router.routes.at(path).unwrap(); + + assert_eq!(matched.parameters.get("Name"), Some("%41lice")); + assert_eq!(matched.parameters.get("Rest"), Some("Photo%2fone.PNG")); + } + + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + + let matched = router + .routes + .at("/CAF%c3%a9/%7EUSER/%41LiCe/Ä%2fOne") + .unwrap(); + + assert_eq!(matched.parameters.get("Name"), Some("%41LiCe")); + assert_eq!(matched.parameters.get("Rest"), Some("Ä%2fOne")); + router.remove_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(router.routes.at("/CAF%C3%A9/~USER/Alice/file").is_err()); +} + +#[test] +fn equivalent_route_collisions_leave_registered_handlers_available() { + let mut router = super::Router::new(); + + router.mount("/%7euser", |_| Response::success("original")); + + let collision = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + router.mount("/~user", |_| Response::success("replacement")); + })); + + assert!(collision.is_err()); + assert!(router.routes.contains("/%7Euser")); + router.mount("/encoded%2Fslash", |_| Response::success("encoded")); + assert!(router.routes.contains("/encoded%2fslash")); + assert!(!router.routes.contains("/encoded/slash")); +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index febb039..ddd321b 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -1085,3 +1085,140 @@ async fn binding_captures_routes_but_preserves_shared_module_registration() { ] ); } + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn default_acceptor_supports_modern_tls_and_rejects_obsolete_choices() { + use openssl::ssl::SslVersion; + + let credentials = acceptor(); + let mut router = Router::new(); + + router.set_certificate( + String::from_utf8( + credentials + .context() + .certificate() + .unwrap() + .to_pem() + .unwrap(), + ) + .unwrap(), + ); + router.set_private_key( + String::from_utf8( + credentials + .context() + .private_key() + .unwrap() + .private_key_to_pem_pkcs8() + .unwrap(), + ) + .unwrap(), + ); + router.set_listener_address("127.0.0.1"); + router.set_port(0); + router.mount("/", |_| Response::success("ready")); + + let bound = router.bind().await.unwrap(); + + serve_bound_requests( + bound, + |address| { + for (version, cipher, accepted) in [ + (SslVersion::TLS1_2, "ECDHE-ECDSA-AES128-GCM-SHA256", true), + (SslVersion::TLS1_3, "DEFAULT", true), + (SslVersion::TLS1_2, "ECDHE-ECDSA-AES128-SHA", false), + (SslVersion::TLS1_1, "ALL:@SECLEVEL=0", false), + (SslVersion::TLS1, "ALL:@SECLEVEL=0", false), + ] { + let mut connector = SslConnector::builder(SslMethod::tls()).unwrap(); + + connector.set_verify(SslVerifyMode::NONE); + connector.set_min_proto_version(Some(version)).unwrap(); + connector.set_max_proto_version(Some(version)).unwrap(); + connector.set_cipher_list(cipher).unwrap(); + + let connection = + connector.build().connect("localhost", connect_tcp(address)); + + if accepted { + exchange( + connection.unwrap(), + &[b"gemini://localhost/\r\n"], + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + } else { + assert!(connection.is_err()); + } + } + }, + std::future::pending(), + ) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn protocol_validation_and_normalization_apply_on_the_wire() { + let mut router = Router::new(); + + router.mount("/redirect", |_| Response::temporary_redirect("1:foo")); + router.mount("/authority", |_| { + Response::temporary_redirect("gemini://a@b@c/") + }); + router.mount("/wildcard", |_| Response::binary_success("body", "text/*")); + router.mount("/language", |_| { + Response::binary_success("body", "text/gemini; lang=bogus_tag") + }); + router.mount("/charset", |_| { + let mut response = Response::success("café"); + + response.with_character_set("iso-8859-1"); + + response + }); + router.mount("/encoded", |_| { + let mut response = Response::binary_success([0xe9], "text/plain"); + + response.with_character_set("iso-8859-1"); + + response + }); + router.mount("/caf%C3%A9/~user/:Name", |context| { + Response::success(context.parameters.get("Name").unwrap()) + }); + serve_requests(router, |address| { + for path in [ + "/redirect", + "/authority", + "/wildcard", + "/language", + "/charset", + ] { + request( + address, + path, + b"40 The server could not encode the response\r\n", + ); + } + + request( + address, + "/encoded", + b"20 text/plain; charset=iso-8859-1\r\n\xe9", + ); + request( + address, + "/caf%c3%a9/%7euser/%41lice", + b"20 text/gemini; charset=utf-8; lang=en\r\n%41lice\n", + ); + }) + .await; +}