diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ce4442f..f743b13 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -3,40 +3,44 @@ name: Check ✅ on: workflow_dispatch: push: - paths: - - "**" pull_request: - paths: - - "**" env: CARGO_TERM_COLOR: always jobs: - check: + format: runs-on: ubuntu-latest steps: - - name: Checkout 🛒 - uses: actions/checkout@v3 - - - name: Toolchain 🧰 - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - components: rustfmt, clippy - override: true - - - name: Check ✅ - uses: actions-rs/cargo@v1 - continue-on-error: false + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master with: - command: check - args: --verbose + toolchain: nightly-2026-05-25 + components: rustfmt + - name: Check Formatting + run: | + cargo +nightly-2026-05-25 fmt --all -- --check + cargo +nightly-2026-05-25 fmt --manifest-path tests/consumer/Cargo.toml -- --check - - name: Test 🧪 - uses: actions-rs/cargo@v1 - continue-on-error: false + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Default + flags: --features windmark/tokio + - name: Tokio + flags: --no-default-features --features windmark/tokio,windmark/auto-deduce-mime + - name: Async-Std + flags: --no-default-features --features windmark/async-std,windmark/auto-deduce-mime + name: Check ${{ matrix.name }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.95.0 with: - command: test - args: --verbose --features auto-deduce-mime,response-macros + components: clippy + - name: Test the Workspace and Consumers + run: cargo +1.95.0 test --workspace ${{ matrix.flags }} + - name: Lint All Targets + run: cargo +1.95.0 clippy --workspace --all-targets ${{ matrix.flags }} -- -D warnings diff --git a/Cargo.toml b/Cargo.toml index 6d08baf..f423f89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,9 @@ members = ["rossweisse"] [package] +autotests = false name = "windmark" -version = "0.7.0" +version = "0.8.0" authors = ["Fuwn "] edition = "2021" description = "An elegant and highly performant async Gemini server framework" @@ -19,9 +20,7 @@ categories = ["web-programming"] [features] default = ["tokio"] -logger = ["pretty_env_logger"] auto-deduce-mime = ["tree_magic_mini"] -response-macros = [] tokio = ["dep:tokio", "tokio-openssl"] async-std = ["dep:async-std", "async-std-openssl"] @@ -38,79 +37,50 @@ tokio = { version = "1.52.3", default-features = false, features = [ "net", "io-util", "macros", + "signal", + "time", ], optional = true } async-trait = "0.1.68" +futures-util = "0.3.31" async-std = { version = "1.12.0", features = ["attributes"], optional = true } # Logging -pretty_env_logger = { version = "0.5.0", optional = true } log = "0.4.30" # URL url = "2.2.2" matchit = "0.7.3" +mime = "0.3.17" +language-tags = "0.3.2" tree_magic_mini = { version = "3.2.2", optional = true } # MIME -paste = "1.0.12" # Token Pasting - [dev-dependencies] -rossweisse = { version = "0.0.3", path = "./rossweisse" } +pretty_env_logger = "0.5.0" +rossweisse = { version = "0.0.4", path = "./rossweisse" } [package.metadata.docs.rs] # `tokio` and `async-std` are mutually exclusive, so document the default # (`tokio`) runtime alongside the optional feature surface. all-features = false -features = ["response-macros", "logger", "auto-deduce-mime"] - -[[example]] -name = "async" -required-features = ["response-macros"] - -[[example]] -name = "async_stateful_module" -required-features = ["response-macros"] - -[[example]] -name = "binary" -required-features = ["response-macros"] - -[[example]] -name = "callbacks" -required-features = ["response-macros"] - -[[example]] -name = "certificate" -required-features = ["response-macros"] - -[[example]] -name = "default_logger" -required-features = ["logger", "response-macros"] - -[[example]] -name = "fix_path" -required-features = ["response-macros"] +features = ["auto-deduce-mime"] [[example]] -name = "parameters" -required-features = ["response-macros"] +name = "simple_async_std" +required-features = ["async-std"] -[[example]] -name = "partial" -required-features = ["response-macros"] +# Private unit-test modules also live in tests, so integration targets are explicit. +[[test]] +name = "binary_response" -[[example]] -name = "query" -required-features = ["response-macros"] +[[test]] +name = "consumer" -[[example]] -name = "responses" -required-features = ["response-macros"] +[[test]] +name = "router_runtime" -[[example]] -name = "stateful_module" -required-features = ["response-macros"] +[[test]] +name = "module" -[[example]] -name = "simple_async_std" -required-features = ["async-std"] +[[test]] +name = "response" diff --git a/README.md b/README.md index a8a8c37..ddd36e6 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,15 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r ## Usage > [!NOTE] -> A macro-based "`struct`-router" is in active development as a simplified -> alternative to the standard server creation approach. Check out -> [Rossweisse](./rossweisse/) for more information! +> Rossweisse lets you define a Windmark router using a struct and route +> attributes. See the [Rossweisse guide](./rossweisse/) for an example. ### Features | Feature | Description | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `default` | Base Windmark framework using [Tokio](https://tokio.rs/) | -| `logger` | Enables the default [`pretty_env_logger`](https://github.com/seanmonstar/pretty-env-logger) integration | -| `auto-deduce-mime` | Exposes `Response`s and macros that automatically fill MIMEs for non-Gemini responses | -| `response-macros` | Simple macros for all `Response`s | +| `auto-deduce-mime` | Enables `Response::binary_success_auto` to infer the MIME type of binary content | | `tokio` | Marks [Tokio](https://tokio.rs/) as the asynchronous runtime | | `async-std` | Marks [`async-std`](https://async.rs/) as the asynchronous runtime | @@ -33,18 +30,12 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r # Cargo.toml [dependencies] -windmark = "0.7.0" +windmark = "0.8.0" tokio = { version = "1.26.0", features = ["full"] } -# If you would like to use the built-in logger (recommended) -# windmark = { version = "0.7.0", features = ["logger"] } - # If you would like to use the built-in MIME deduction when `Success`-ing a file # (recommended) -# windmark = { version = "0.7.0", features = ["auto-deduce-mime"] } - -# If you would like to use macro-based responses (as seen below) -# windmark = { version = "0.7.0", features = ["response-macros"] } +# windmark = { version = "0.8.0", features = ["auto-deduce-mime"] } ``` ### Implementing a Windmark Server @@ -73,6 +64,7 @@ async fn main() -> Result<(), Box> { ```rust // src/main.rs +use rossweisse::route; use windmark::response::Response; #[rossweisse::router] @@ -80,7 +72,7 @@ struct Router; #[rossweisse::router] impl Router { - #[rossweisse::route(index)] + #[route(index)] pub fn index( _context: windmark::context::RouteContext, ) -> Response { @@ -97,7 +89,9 @@ Examples can be found within the [`examples/`](https://github.com/gemrest/windmark/tree/main/examples) directory along with a rundown of each of their purposes and useful facts. -Run an example by cloning this repository and running `cargo run --example example_name`. +Each entry in [the examples guide](./examples/README.md) lists the required +features. Generate local credentials with `just gen-key`, then use the listed +command or `just example example_name` (optionally followed by `async-std`). ## Modules diff --git a/examples/README.md b/examples/README.md index 8b3fb51..f9f5c5e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ ## [Async Stateful Module](./async_stateful_module.rs) -`cargo run --example async_stateful_module --features response-macros` +`cargo run --example async_stateful_module` Demonstrates use of the `AsyncModule` trait by implementing the module `Clicker` which tracks the global number of visits to the capsule. @@ -12,37 +12,36 @@ tracked for clicks. ## [Async](./async.rs) -`cargo run --example async --features response-macros` +`cargo run --example async` -Demonstrates use of async routes through an async response macro and -implementing a click tracker using a shared variable through an thread-safe, -async mutex. +This example shows how to define async route handlers and implement a click +tracker using a shared variable protected by an async mutex. ## [Binary](./binary.rs) -`cargo run --example binary --features response-macros` +`cargo run --example binary` Demonstrates the binary response functionality by using both manual and automatic mime resolution (`--features auto-deduce-mime`). ## [Callbacks](./callbacks.rs) -`cargo run --example callbacks --features response-macros` +`cargo run --example callbacks` -Demonstrates use of the pre and post-route callback handlers. +This example shows how to register pre-route and post-route callbacks. ## [Certificate](./certificate.rs) -`cargo run --example certificate --features response-macros` +`cargo run --example certificate` Demonstrate the various certificate related responses as well as reading the client certificate to give conditional access. ## [Default Logger](./default_logger.rs) -`cargo run --example default_logger --features logger,response-macros` +`cargo run --example default_logger` -A simple example showing the use of the default default logger implementation. +A simple example showing how an application initialises a logger for Windmark. ## [Empty](./empty.rs) @@ -54,11 +53,13 @@ An empty example which starts up a server but has no mounted routes. `cargo run --example error_handler` -Creates an intentional error within a route, invoking the error handler. +This example shows how to handle requests that do not match a route. It also +includes an `/error` route that deliberately panics; this panic does not invoke +the error handler. ## [Fix Path](./fix_path.rs) -`cargo run --example fix_path --features response-macros` +`cargo run --example fix_path` A simple example which demonstrates use of the path fixer that attempts to resolve the closest match of a route when an invalid route is visited. @@ -79,13 +80,13 @@ Demonstrate how to modify the MIME of a response before use. ## [Parameters](./parameters.rs) -`cargo run --example parameters --features response-macros` +`cargo run --example parameters` Demonstrate the use of route parameters (not URL queries). ## [Partial](./partial.rs) -`cargo run --example partial --features response-macros` +`cargo run --example partial` Demonstrates use of appending headers and footers to routes, globally. @@ -93,13 +94,13 @@ If you would like to conditionally append headers and footers based on route, pl ## [Query](./query.rs) -`cargo run --example query --features response-macros` +`cargo run --example query` Demonstrates the inspection of URL queries parameters. ## [Responses](./responses.rs) -`cargo run --example responses --features response-macros` +`cargo run --example responses` Demonstrates the use of a wide variety of responses, additionally exposing the flexibility of response bodies types. @@ -109,7 +110,8 @@ Demonstrates the use of a wide variety of responses, additionally exposing the f Demonstrates how to explicitly specify Windmark to use the [`async-std`](https://github.com/async-rs/async-std) runtime. -If the `async-std` feature is NOT enabled, Windmark will default to using Tokio as the async runtime. +Windmark uses Tokio when its default features are enabled. If you disable +default features, you must explicitly enable either `tokio` or `async-std`. ## [Simple Tokio](./simple_tokio.rs) @@ -119,7 +121,7 @@ Demonstrates how to explicitly specify Windmark to use the [Tokio](https://githu ## [Stateful Module](./stateful_module.rs) -`cargo run --example stateful_module --features response-macros` +`cargo run --example stateful_module` Demonstrates use of `Module`s by implementing a click tracker diff --git a/examples/async.rs b/examples/async.rs index d7fe6f5..134ef68 100644 --- a/examples/async.rs +++ b/examples/async.rs @@ -1,4 +1,6 @@ -//! `cargo run --example async --features response-macros` +//! `cargo run --example async` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -18,15 +20,14 @@ async fn main() -> Result<(), Box> { *clicks += 1; - windmark::response::Response::success(*clicks) + Response::success(*clicks) + } + }); + router.mount("/async", |_| { + async { + Response::success("This response was sent using an asynchronous handler.") } }); - router.mount( - "/macro", - windmark::success_async!( - async { "This response was sent using an asynchronous macro." }.await - ), - ); router.run().await } diff --git a/examples/async_stateful_module.rs b/examples/async_stateful_module.rs index 6391611..5634301 100644 --- a/examples/async_stateful_module.rs +++ b/examples/async_stateful_module.rs @@ -1,10 +1,11 @@ -//! `cargo run --example async_stateful_module --features response-macros` +//! `cargo run --example async_stateful_module` -use windmark::{context::HookContext, router::Router}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use windmark::{context::HookContext, response::Response, router::Router}; #[derive(Default)] struct Clicker { - clicks: std::sync::Arc>, + clicks: AtomicUsize, } #[async_trait::async_trait] @@ -13,38 +14,37 @@ impl windmark::module::AsyncModule for Clicker { println!("module 'clicker' has been attached!"); } - async fn on_pre_route(&mut self, context: &HookContext) { - *self.clicks.lock().unwrap() += 1; + async fn on_pre_route(&self, context: &HookContext) { + let clicks = self.clicks.fetch_add(1, Ordering::Relaxed) + 1; println!( "module 'clicker' has been called before the route '{}' with {} clicks!", context.url.path(), - self.clicks.lock().unwrap() + clicks ); } - async fn on_post_route(&mut self, context: &HookContext) { + async fn on_post_route(&self, context: &HookContext) { println!( "module 'clicker' clicker has been called after the route '{}' with {} \ clicks!", context.url.path(), - self.clicks.lock().unwrap() + self.clicks.load(Ordering::Relaxed) ); } } #[windmark::main] async fn main() -> Result<(), Box> { + pretty_env_logger::formatted_builder() + .parse_filters("windmark=trace") + .try_init()?; + let mut router = Router::new(); router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - #[cfg(feature = "logger")] - { - router.enable_default_logger(true); - } - router.attach_async(Clicker::default()); - router.mount("/", windmark::success!("Hello!")); - + router.attach_async(Clicker::default()).await; + router.mount("/", |_| Response::success("Hello!")); router.run().await } diff --git a/examples/binary.rs b/examples/binary.rs index ae8f5e3..3501b15 100644 --- a/examples/binary.rs +++ b/examples/binary.rs @@ -1,8 +1,10 @@ -//! `cargo run --example binary --features response-macros` +//! `cargo run --example binary` //! //! Optionally, you can run this example with the `auto-deduce-mime` feature //! enabled. +use windmark::response::Response; + #[windmark::main] async fn main() -> Result<(), Box> { let mut router = windmark::router::Router::new(); @@ -10,14 +12,14 @@ async fn main() -> Result<(), Box> { router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); #[cfg(feature = "auto-deduce-mime")] - router.mount("/automatic", { - windmark::binary_success!(include_bytes!("../LICENSE-MIT")) + router.mount("/automatic", |_| { + Response::binary_success_auto(include_bytes!("../LICENSE-MIT")) }); - router.mount("/specific", { - windmark::binary_success!(include_bytes!("../LICENSE-MIT"), "text/plain") + router.mount("/specific", |_| { + Response::binary_success(include_bytes!("../LICENSE-MIT"), "text/plain") }); - router.mount("/direct", { - windmark::binary_success!("This is a string.", "text/plain") + router.mount("/direct", |_| { + Response::binary_success("This is a string.", "text/plain") }); router.run().await diff --git a/examples/callbacks.rs b/examples/callbacks.rs index c36d16a..cd5cad9 100644 --- a/examples/callbacks.rs +++ b/examples/callbacks.rs @@ -1,30 +1,30 @@ -//! `cargo run --example callbacks --features response-macros` +//! `cargo run --example callbacks` -use windmark::context::HookContext; +use windmark::{context::HookContext, response::Response}; #[windmark::main] async fn main() -> Result<(), Box> { windmark::router::Router::new() .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") - .mount("/", windmark::success!("Hello!")) + .mount("/", |_| Response::success("Hello!")) .set_pre_route_callback(|context: &HookContext| { println!( "accepted connection from {} to {}", context.peer_address.unwrap().ip(), - context.url.to_string() + context.url ) }) - .set_post_route_callback( - |context: &HookContext, content: &mut windmark::response::Response| { - content.content = content.content.replace("Hello", "Hi"); + .set_post_route_callback(|context: &HookContext, content: &mut Response| { + if let Some(text) = content.content_mut() { + *text = text.replace("Hello", "Hi"); + } - println!( - "closed connection from {}", - context.peer_address.unwrap().ip() - ) - }, - ) + println!( + "prepared response for {}", + context.peer_address.unwrap().ip() + ) + }) .run() .await } diff --git a/examples/certificate.rs b/examples/certificate.rs index 6c3e1d0..abc2ea9 100644 --- a/examples/certificate.rs +++ b/examples/certificate.rs @@ -1,4 +1,4 @@ -//! `cargo run --example certificate --features response-macros` +//! `cargo run --example certificate` use windmark::response::Response; @@ -26,10 +26,9 @@ async fn main() -> Result<(), Box> { ) } }) - .mount( - "/invalid", - windmark::certificate_not_valid!("Your certificate is invalid."), - ) + .mount("/invalid", |_| { + Response::certificate_not_valid("Your certificate is invalid.") + }) .run() .await } diff --git a/examples/default_logger.rs b/examples/default_logger.rs index 7d09b0a..ea79d23 100644 --- a/examples/default_logger.rs +++ b/examples/default_logger.rs @@ -1,23 +1,21 @@ -//! `cargo run --example default_logger --features logger,response-macros` +//! `cargo run --example default_logger` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { + pretty_env_logger::formatted_builder() + .parse_filters("windmark=trace") + .try_init()?; + let mut router = windmark::router::Router::new(); router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - #[cfg(feature = "logger")] - { - router.enable_default_logger(true); - } - router.mount( - "/", - windmark::success!({ - log::info!("Hello!"); - - "Hello!" - }), - ); + router.mount("/", |_| { + log::info!("Hello!"); + Response::success("Hello!") + }); router.run().await } diff --git a/examples/error_handler.rs b/examples/error_handler.rs index 7d3ca25..3fd18c6 100644 --- a/examples/error_handler.rs +++ b/examples/error_handler.rs @@ -4,9 +4,9 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, }; - use windmark::response::Response; +#[allow(clippy::unnecessary_literal_unwrap)] #[windmark::main] async fn main() -> Result<(), Box> { let error_count = Arc::new(AtomicUsize::new(0)); @@ -18,12 +18,12 @@ async fn main() -> Result<(), Box> { let count = error_count.fetch_add(1, Ordering::Relaxed) + 1; println!("{count} errors so far"); - Response::permanent_failure("e") }) .mount("/error", |_| { let nothing = None::; + // This deliberately panics without invoking the unmatched-route handler. Response::success(nothing.unwrap()) }) .run() diff --git a/examples/fix_path.rs b/examples/fix_path.rs index fc87476..e409bf9 100644 --- a/examples/fix_path.rs +++ b/examples/fix_path.rs @@ -1,6 +1,6 @@ -//! `cargo run --example fix_path --features response-macros` +//! `cargo run --example fix_path` -use windmark::router_option::RouterOption; +use windmark::{response::Response, router_option::RouterOption}; #[windmark::main] async fn main() -> Result<(), Box> { @@ -12,10 +12,9 @@ async fn main() -> Result<(), Box> { RouterOption::AddMissingTrailingSlash, RouterOption::AllowCaseInsensitiveLookup, ]) - .mount( - "/close", - windmark::success!("Visit '/close/'; you should be close enough!"), - ) + .mount("/close", |_| { + Response::success("Visit '/close/'; you should be close enough!") + }) .run() .await } diff --git a/examples/mime.rs b/examples/mime.rs index b61c844..7544109 100644 --- a/examples/mime.rs +++ b/examples/mime.rs @@ -6,9 +6,11 @@ async fn main() -> Result<(), Box> { .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") .mount("/mime", |_| { - windmark::response::Response::success("Hello!".to_string()) - .with_mime("text/plain") - .clone() + let mut response = windmark::response::Response::success("Hello!"); + + response.with_mime("text/plain"); + + response }) .run() .await diff --git a/examples/parameters.rs b/examples/parameters.rs index 153e64a..5479671 100644 --- a/examples/parameters.rs +++ b/examples/parameters.rs @@ -1,6 +1,6 @@ -//! `cargo run --example parameters --features response-macros` +//! `cargo run --example parameters` -use windmark::success; +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -9,24 +9,22 @@ async fn main() -> Result<(), Box> { .set_certificate_file("windmark_public.pem") .mount( "/language/:language", - success!( - context, - format!( + |context: windmark::context::RouteContext| { + Response::success(format!( "Your language of choice is {}.", context.parameters.get("language").unwrap() - ) - ), + )) + }, ) .mount( "/name/:first/:last", - success!( - context, - format!( + |context: windmark::context::RouteContext| { + Response::success(format!( "Your name is {} {}.", context.parameters.get("first").unwrap(), context.parameters.get("last").unwrap() - ) - ), + )) + }, ) .run() .await diff --git a/examples/partial.rs b/examples/partial.rs index e92c8b8..7583986 100644 --- a/examples/partial.rs +++ b/examples/partial.rs @@ -1,4 +1,6 @@ -//! `cargo run --example partial --features response-macros` +//! `cargo run --example partial` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -14,7 +16,7 @@ async fn main() -> Result<(), Box> { .add_footer(|_: &windmark::context::RouteContext| { "\nCopyright (C) 2022".to_string() }) - .mount("/", windmark::success!("Hello!")) + .mount("/", |_| Response::success("Hello!")) .run() .await } diff --git a/examples/query.rs b/examples/query.rs index 12a0a97..ca55a6f 100644 --- a/examples/query.rs +++ b/examples/query.rs @@ -1,20 +1,18 @@ -//! `cargo run --example query --features response-macros` +//! `cargo run --example query` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { windmark::router::Router::new() .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") - .mount( - "/query", - windmark::success!( - context, - format!( - "You provided the following queries: '{:?}'", - windmark::utilities::queries_from_url(&context.url) - ) - ), - ) + .mount("/query", |context: windmark::context::RouteContext| { + Response::success(format!( + "You provided the following queries: '{:?}'", + windmark::utilities::queries_from_url(&context.url) + )) + }) .run() .await } diff --git a/examples/responses.rs b/examples/responses.rs index 656c734..2182e61 100644 --- a/examples/responses.rs +++ b/examples/responses.rs @@ -1,31 +1,29 @@ -//! `cargo run --example responses --features response-macros` +//! `cargo run --example responses` -use windmark::success; +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { windmark::router::Router::new() .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") - .mount( - "/", - success!( - "# Index\n\nWelcome!\n\n=> /test Test Page\n=> /time Unix Epoch" - ), - ) - .mount("/test", success!("This is a test page.\n=> / back")) - .mount( - "/failure", - windmark::temporary_failure!("Woops ... temporarily."), - ) - .mount( - "/time", - success!(std::time::UNIX_EPOCH.elapsed().unwrap().as_nanos()), - ) - .mount( - "/redirect", - windmark::permanent_redirect!("gemini://localhost/test"), - ) + .mount("/", |_| { + Response::success( + "# Index\n\nWelcome!\n\n=> /test Test Page\n=> /time Unix Epoch", + ) + }) + .mount("/test", |_| { + Response::success("This is a test page.\n=> / back") + }) + .mount("/failure", |_| { + Response::temporary_failure("Woops ... temporarily.") + }) + .mount("/time", |_| { + Response::success(std::time::UNIX_EPOCH.elapsed().unwrap().as_nanos()) + }) + .mount("/redirect", |_| { + Response::permanent_redirect("gemini://localhost/test") + }) .run() .await } diff --git a/examples/stateful_module.rs b/examples/stateful_module.rs index 42c4ca1..f9156f5 100644 --- a/examples/stateful_module.rs +++ b/examples/stateful_module.rs @@ -1,10 +1,11 @@ -//! `cargo run --example stateful_module --features response-macros` +//! `cargo run --example stateful_module` -use windmark::{context::HookContext, router::Router}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use windmark::{context::HookContext, response::Response, router::Router}; #[derive(Default)] struct Clicker { - clicks: usize, + clicks: AtomicUsize, } impl windmark::module::Module for Clicker { @@ -12,38 +13,38 @@ impl windmark::module::Module for Clicker { println!("module 'clicker' has been attached!"); } - fn on_pre_route(&mut self, context: &HookContext) { - self.clicks += 1; + fn on_pre_route(&self, context: &HookContext) { + let clicks = self.clicks.fetch_add(1, Ordering::Relaxed) + 1; println!( "module 'clicker' has been called before the route '{}' with {} clicks!", context.url.path(), - self.clicks, + clicks, ); } - fn on_post_route(&mut self, context: &HookContext) { + fn on_post_route(&self, context: &HookContext) { println!( "module 'clicker' clicker has been called after the route '{}' with {} \ clicks!", context.url.path(), - self.clicks, + self.clicks.load(Ordering::Relaxed), ); } } #[windmark::main] async fn main() -> Result<(), Box> { + pretty_env_logger::formatted_builder() + .parse_filters("windmark=trace") + .try_init()?; + let mut router = Router::new(); router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - #[cfg(feature = "logger")] - { - router.enable_default_logger(true); - } router.attach(Clicker::default()); - router.mount("/", windmark::success!("Hello!")); + router.mount("/", |_| Response::success("Hello!")); router.run().await } diff --git a/justfile b/justfile index e3e89f8..4c636bc 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ import? 'cargo.just' set allow-duplicate-recipes := true -default-features := "--features=logger,auto-deduce-mime,response-macros," +default-features := "--features=auto-deduce-mime," default: @just --list @@ -17,7 +17,8 @@ fmt: [private] generic-task task async-feature: - rustup run nightly cargo {{ task }} --no-default-features \ + PATH="$(dirname "$(rustup which --toolchain nightly rustc)"):$PATH" \ + cargo {{ task }} --no-default-features \ {{ default-features }}{{ async-feature }} check async-feature: diff --git a/rossweisse/Cargo.toml b/rossweisse/Cargo.toml index bfbc98c..c90bd30 100644 --- a/rossweisse/Cargo.toml +++ b/rossweisse/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "rossweisse" -version = "0.0.3" +version = "0.0.4" authors = ["Fuwn "] edition = "2021" description = "`struct`-based Router Framework for Windmark" @@ -19,3 +19,6 @@ proc-macro = true [dependencies] quote = "1.0.26" # Quasi-quoting syn = { version = "2.0.15", features = ["full"] } # Source Code Parsing + +[dev-dependencies] +windmark = { path = "..", default-features = false } diff --git a/rossweisse/README.md b/rossweisse/README.md index ef35bb5..ee47bf8 100644 --- a/rossweisse/README.md +++ b/rossweisse/README.md @@ -4,9 +4,10 @@ ## Usage -Rossweisse is in it's infancy, and a much comprehensive interface is planned. +Rossweisse provides a struct wrapper around Windmark. Routes are associated +functions and do not receive `self`. -For now, a simple Rosswiesse router can be implemented like this: +For now, a simple Rossweisse router can be implemented like this: ```rust use rossweisse::route; diff --git a/rossweisse/src/implementations/route.rs b/rossweisse/src/implementations/route.rs index d38df93..edfa9a0 100644 --- a/rossweisse/src/implementations/route.rs +++ b/rossweisse/src/implementations/route.rs @@ -1,5 +1,18 @@ use proc_macro::TokenStream; -pub fn route(_arguments: TokenStream, item: &syn::ItemFn) -> TokenStream { +pub fn route(arguments: TokenStream, item: &syn::ItemFn) -> TokenStream { + if !arguments.is_empty() { + let argument = syn::parse_macro_input!(arguments as syn::Ident); + + if argument != "index" { + return syn::Error::new_spanned( + argument, + "the only supported route argument is `index`", + ) + .to_compile_error() + .into(); + } + } + quote::quote! { #item }.into() } diff --git a/rossweisse/src/implementations/router.rs b/rossweisse/src/implementations/router.rs index f7b6094..2d330c2 100644 --- a/rossweisse/src/implementations/router.rs +++ b/rossweisse/src/implementations/router.rs @@ -1,6 +1,48 @@ +use syn::{punctuated::Punctuated, Meta, Token}; + mod fields; mod methods; mod parser; pub use fields::fields; pub use methods::methods; + +fn conditional_attributes( + attributes: &[syn::Attribute], +) -> Vec { + attributes + .iter() + .filter_map(|attribute| { + conditional_meta(&attribute.meta).map(|meta| syn::parse_quote!(#[#meta])) + }) + .collect() +} + +fn conditional_meta(meta: &syn::Meta) -> Option { + if meta.path().is_ident("cfg") { + return Some(meta.clone()); + } + + let syn::Meta::List(list) = meta else { + return None; + }; + + if !list.path.is_ident("cfg_attr") { + return None; + } + + let arguments = list + .parse_args_with(Punctuated::::parse_terminated) + .ok()?; + let mut arguments = arguments.into_iter(); + let predicate = arguments.next()?; + let conditions: Vec<_> = arguments + .filter_map(|meta| conditional_meta(&meta)) + .collect(); + + if conditions.is_empty() { + None + } else { + Some(syn::parse_quote!(cfg_attr(#predicate, #(#conditions),*))) + } +} diff --git a/rossweisse/src/implementations/router/fields.rs b/rossweisse/src/implementations/router/fields.rs index 8f7ee01..b3f68a4 100644 --- a/rossweisse/src/implementations/router/fields.rs +++ b/rossweisse/src/implementations/router/fields.rs @@ -1,70 +1,106 @@ use proc_macro::TokenStream; use quote::quote; -use syn::punctuated::Punctuated; +use std::collections::HashSet; -pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { - let field_initializers = syn::parse_macro_input!( - arguments as super::parser::FieldInitializers - ); - let router_identifier = item.ident; - let (named_fields, has_fields) = match item.fields { - syn::Fields::Named(fields) => (fields, true), - syn::Fields::Unit => - ( - syn::FieldsNamed { - brace_token: syn::token::Brace::default(), - named: Punctuated::default(), - }, - false, - ), - syn::Fields::Unnamed(_) => - panic!( - "`#[rossweisse::router]` can only be used on `struct`s with named \ - fields or unit structs" - ), +pub fn fields( + arguments: TokenStream, + mut item: syn::ItemStruct, +) -> TokenStream { + let field_initializers = + syn::parse_macro_input!(arguments as super::parser::FieldInitializers); + + if matches!(item.fields, syn::Fields::Unit) { + item.fields = syn::Fields::Named(syn::parse_quote!({})); + item.semi_token = None; + } + + let syn::Fields::Named(fields) = &mut item.fields else { + return syn::Error::new_spanned( + item.fields, + "`#[rossweisse::router]` requires a struct with named fields or a unit \ + struct", + ) + .to_compile_error() + .into(); }; - let new_method_fields = named_fields.named.iter().map(|field| { + let mut supplied_fields = HashSet::new(); + + for initializer in &field_initializers.0 { + if !supplied_fields.insert(initializer.identifier.to_string()) { + return syn::Error::new_spanned( + &initializer.identifier, + "duplicate field initialiser", + ) + .to_compile_error() + .into(); + } + + if !fields + .named + .iter() + .any(|field| field.ident.as_ref() == Some(&initializer.identifier)) + { + return syn::Error::new_spanned( + &initializer.identifier, + "unknown field initialiser", + ) + .to_compile_error() + .into(); + } + } + + if let Some(field) = fields + .named + .iter() + .find(|field| field.ident.as_ref().is_some_and(|name| name == "router")) + { + return syn::Error::new_spanned( + field, + "the field name `router` is reserved by Rossweisse", + ) + .to_compile_error() + .into(); + } + + let initializers = fields.named.iter().map(|field| { let name = &field.ident; - let initialiser: syn::Expr = field_initializers + let conditions = super::conditional_attributes(&field.attrs); + let initializer: syn::Expr = field_initializers .0 .iter() - .find(|initialiser| name.as_ref() == Some(&initialiser.ident)) + .find(|initializer| name.as_ref() == Some(&initializer.identifier)) .map_or_else( || syn::parse_quote! { ::std::default::Default::default() }, - |initialiser| initialiser.expr.clone(), + |initializer| initializer.expression.clone(), ); quote! { - #name: #initialiser, + #(#conditions)* + #name: #initializer, } }); - let new_methods = if has_fields { - quote! { - fn _new() -> Self { - Self { - #(#new_method_fields)* - router: ::windmark::router::Router::new(), - } - } - } - } else { - quote! { - fn _new() -> Self { - Self { - router: ::windmark::router::Router::new(), - } - } + let initialization = quote! { + Self { + #(#initializers)* + router: ::windmark::router::Router::new(), } }; - let output_fields = named_fields.named; - let output = quote! { - struct #router_identifier { - #output_fields - router: ::windmark::router::Router, - } - impl #router_identifier { - #new_methods + fields + .named + .push(syn::parse_quote!(router: ::windmark::router::Router)); + + let name = &item.ident; + let conditions = super::conditional_attributes(&item.attrs); + let (implementation_generics, type_generics, where_clause) = + item.generics.split_for_impl(); + + quote! { + #item + + #(#conditions)* + impl #implementation_generics #name #type_generics #where_clause { + fn _new() -> Self { #initialization } pub async fn run(&mut self) -> Result<(), Box> { self.router.run().await @@ -74,7 +110,6 @@ pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { &mut self.router } } - }; - - output.into() + } + .into() } diff --git a/rossweisse/src/implementations/router/methods.rs b/rossweisse/src/implementations/router/methods.rs index cc1d36a..3e16e96 100644 --- a/rossweisse/src/implementations/router/methods.rs +++ b/rossweisse/src/implementations/router/methods.rs @@ -1,61 +1,66 @@ use proc_macro::TokenStream; +use quote::quote; -pub fn methods( - _arguments: TokenStream, - mut item: syn::ItemImpl, -) -> TokenStream { - let routes = item - .items - .iter_mut() - .filter_map(|item| { - let syn::ImplItem::Fn(method) = item else { - return None; - }; - let route_attribute = method - .attrs - .iter() - .find(|attribute| attribute.path().is_ident("route"))?; - let is_index = route_attribute - .parse_args::() - .is_ok_and(|argument| argument == "index"); +pub fn methods(_arguments: TokenStream, item: &syn::ItemImpl) -> TokenStream { + let mut mounts = Vec::new(); - if is_index { - method.sig.ident = - syn::Ident::new("__router_index", method.sig.ident.span()); - } + for member in &item.items { + let syn::ImplItem::Fn(method) = member else { + continue; + }; + let name = &method.sig.ident; + + if ["new", "_new", "run", "router"] + .iter() + .any(|reserved| name == reserved) + { + return syn::Error::new_spanned( + name, + "this method name is reserved by Rossweisse", + ) + .to_compile_error() + .into(); + } - Some(method.sig.ident.clone()) - }) - .collect::>(); - let (implementation_generics, type_generics, where_clause) = + let Some(route_attribute) = method.attrs.iter().find(|attribute| { + attribute + .path() + .segments + .last() + .is_some_and(|segment| segment.ident == "route") + }) else { + continue; + }; + let is_index = route_attribute + .parse_args::() + .is_ok_and(|argument| argument == "index"); + let path = if is_index { + "/".to_owned() + } else { + format!("/{name}") + }; + let conditions = super::conditional_attributes(&method.attrs); + + mounts.push(quote! { + #(#conditions)* + router.router.mount(#path, |context| Self::#name(context)); + }); + } + + let (implementation_generics, _, where_clause) = item.generics.split_for_impl(); let name = &item.self_ty; - let route_paths = routes - .iter() - .map(|route| { - format!( - "/{}", - if route == "__router_index" { - String::new() - } else { - route.to_string() - } - ) - }) - .collect::>(); + let conditions = super::conditional_attributes(&item.attrs); - quote::quote! { + quote! { #item - impl #implementation_generics #name #type_generics #where_clause { + #(#conditions)* + impl #implementation_generics #name #where_clause { pub fn new() -> Self { let mut router = Self::_new(); - #( - router.router.mount(#route_paths, |context| { - Self::#routes(context) - }); - )* + #(#mounts)* router } diff --git a/rossweisse/src/implementations/router/parser.rs b/rossweisse/src/implementations/router/parser.rs index 230b1d6..f08a01e 100644 --- a/rossweisse/src/implementations/router/parser.rs +++ b/rossweisse/src/implementations/router/parser.rs @@ -1,4 +1,33 @@ -mod field_initializer; -mod field_initializers; +use syn::{ + parse::{self, Parse}, + punctuated::Punctuated, + Expr, + Ident, + Token, +}; -pub use field_initializers::FieldInitializers; +pub struct FieldInitializer { + pub identifier: Ident, + pub expression: Expr, +} + +impl Parse for FieldInitializer { + fn parse(input: parse::ParseStream<'_>) -> syn::Result { + let identifier = input.parse()?; + let _: Token![=] = input.parse()?; + let expression = input.parse()?; + + Ok(Self { + identifier, + expression, + }) + } +} + +pub struct FieldInitializers(pub Punctuated); + +impl Parse for FieldInitializers { + fn parse(input: parse::ParseStream<'_>) -> syn::Result { + Punctuated::parse_terminated(input).map(Self) + } +} diff --git a/rossweisse/src/implementations/router/parser/field_initializer.rs b/rossweisse/src/implementations/router/parser/field_initializer.rs deleted file mode 100644 index fe51ce1..0000000 --- a/rossweisse/src/implementations/router/parser/field_initializer.rs +++ /dev/null @@ -1,22 +0,0 @@ -use syn::parse::{self, Parse}; - -pub struct FieldInitializer { - pub ident: syn::Ident, - #[allow(unused)] - eq_token: syn::Token![=], - pub expr: T, -} - -impl Parse for FieldInitializer { - fn parse(input: parse::ParseStream<'_>) -> syn::Result { - let ident = input.parse()?; - let eq_token = input.parse()?; - let expr = input.parse()?; - - Ok(Self { - ident, - eq_token, - expr, - }) - } -} diff --git a/rossweisse/src/implementations/router/parser/field_initializers.rs b/rossweisse/src/implementations/router/parser/field_initializers.rs deleted file mode 100644 index 81d171a..0000000 --- a/rossweisse/src/implementations/router/parser/field_initializers.rs +++ /dev/null @@ -1,11 +0,0 @@ -use syn::parse::{self, Parse}; - -use super::field_initializer::FieldInitializer; - -pub struct FieldInitializers(pub Vec>); - -impl Parse for FieldInitializers { - fn parse(input: parse::ParseStream<'_>) -> syn::Result { - Ok(Self(syn::punctuated::Punctuated::, syn::Token![,]>::parse_terminated(input)?.into_iter().collect())) - } -} diff --git a/rossweisse/src/lib.rs b/rossweisse/src/lib.rs index 8cda9a2..b446d07 100644 --- a/rossweisse/src/lib.rs +++ b/rossweisse/src/lib.rs @@ -15,12 +15,12 @@ mod implementations; use proc_macro::TokenStream; use syn::Item; -/// Marks a `struct` as a router or marks an `impl` block as a router -/// implementation +/// Mark a `struct` as a router or an `impl` block as a router implementation. /// -/// # Panics -/// -/// Panics if used on an item that is not a `struct` or `impl` block. +/// Struct attributes and generics are preserved. Each field initialiser must +/// name a declared field, and repeated names are rejected. The `router` field +/// and the `new`, `_new`, `run`, and `router` methods are reserved for +/// generated code. /// /// # Examples /// @@ -45,16 +45,22 @@ use syn::Item; pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { match syn::parse::(item) { Ok(Item::Struct(item)) => implementations::fields(arguments, item), - Ok(Item::Impl(item)) => implementations::methods(arguments, item), - _ => panic!("`#[rossweisse::router]` can only be used on `struct`s"), + Ok(Item::Impl(ref item)) => implementations::methods(arguments, item), + Ok(item) => + syn::Error::new_spanned( + item, + "`#[rossweisse::router]` requires a struct or impl block", + ) + .to_compile_error() + .into(), + Err(error) => error.to_compile_error().into(), } } -/// Marks a method of a router implementation as a route to mount -/// -/// # Panics +/// Mark a method of a router implementation as a route to mount. /// -/// Panics if used on an item that is not a function. +/// Within a router implementation, `#[route(index)]` mounts the method at `/` +/// without changing its name. /// /// # Examples /// @@ -62,6 +68,9 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { /// use rossweisse::route; /// use windmark::response::Response; /// +/// # #[rossweisse::router] +/// # struct Router; +/// # /// #[rossweisse::router] /// impl Router { /// #[route] @@ -74,6 +83,13 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { pub fn route(arguments: TokenStream, item: TokenStream) -> TokenStream { match syn::parse::(item) { Ok(Item::Fn(ref item)) => implementations::route(arguments, item), - _ => panic!("`#[rossweisse::route]` can only be used on `fn`s"), + Ok(item) => + syn::Error::new_spanned( + item, + "`#[rossweisse::route]` requires a function", + ) + .to_compile_error() + .into(), + Err(error) => error.to_compile_error().into(), } } diff --git a/rustfmt.toml b/rustfmt.toml index dab6ef1..aa979af 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,8 +1,6 @@ condense_wildcard_suffixes = true edition = "2021" enum_discrim_align_threshold = 20 -# error_on_line_overflow = true -# error_on_unformatted = true fn_single_line = true force_multiline_blocks = true format_code_in_doc_comments = true @@ -16,7 +14,7 @@ newline_style = "Unix" normalize_comments = true normalize_doc_attributes = true reorder_impl_items = true -group_imports = "StdExternalCrate" +group_imports = "One" reorder_modules = true struct_field_align_threshold = 20 struct_lit_single_line = false diff --git a/src/context/parameters.rs b/src/context/parameters.rs index 413a241..8a5cd4d 100644 --- a/src/context/parameters.rs +++ b/src/context/parameters.rs @@ -11,6 +11,26 @@ impl Parameters { ) } + pub(crate) fn from_lookup( + parameters: &matchit::Params<'_, '_>, + lookup: &str, + original: &str, + ) -> 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. + let start = value.as_ptr().addr() - lookup.as_ptr().addr(); + let end = start + value.len(); + + (name.to_owned(), original[start..end].to_owned()) + }) + .collect(), + ) + } + #[must_use] pub fn get(&self, key: &str) -> Option<&str> { self diff --git a/src/handler.rs b/src/handler.rs index 2727823..47a6df9 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,9 +1,8 @@ -use async_trait::async_trait; - use crate::{ context::{ErrorContext, HookContext, RouteContext}, response::Response, }; +use async_trait::async_trait; #[async_trait] pub trait RouteResponse: Send + Sync { diff --git a/src/module.rs b/src/module.rs index 49991fc..dc00db4 100644 --- a/src/module.rs +++ b/src/module.rs @@ -1,24 +1,44 @@ use crate::context::HookContext; -pub trait Module { - /// Called right after the module is attached. +/// Synchronous modules run hooks in registration order for each request. +/// +/// Hook phases run exclusively across requests unless +/// `RouterOption::AllowConcurrentModules` is enabled. Concurrent phases may +/// invoke the same module simultaneously; modules own any synchronisation +/// needed by their state. Attachment still receives exclusive access. +/// If a hook panics, subsequent invocations skip that module. Invocations +/// already running may finish; other modules remain available. +pub trait Module: Send + Sync { + /// The router calls this hook during attachment, before registering + /// request hooks. fn on_attach(&mut self, _: &mut crate::router::Router) {} - /// Called before a route is mounted. - fn on_pre_route(&mut self, _: &HookContext) {} + /// The router calls this hook before dispatching a request, even if the + /// request does not match a route. + fn on_pre_route(&self, _: &HookContext) {} - /// Called after a route is mounted. - fn on_post_route(&mut self, _: &HookContext) {} + /// The router calls this hook after the handler completes, before + /// response serialisation. + fn on_post_route(&self, _: &HookContext) {} } +/// Asynchronous modules run hooks in registration order for each request. +/// +/// Hook phases run exclusively across requests unless +/// `RouterOption::AllowConcurrentModules` is enabled. Concurrent phases may +/// invoke the same module while an earlier invocation awaits work. Modules +/// own their state synchronisation; attachment still receives exclusive access. #[async_trait::async_trait] pub trait AsyncModule: Send + Sync { - /// Called right after the module is attached. + /// The router calls this hook during attachment, before registering + /// request hooks. async fn on_attach(&mut self, _: &mut crate::router::Router) {} - /// Called before a route is mounted. - async fn on_pre_route(&mut self, _: &HookContext) {} + /// The router calls this hook before dispatching a request, even if the + /// request does not match a route. + async fn on_pre_route(&self, _: &HookContext) {} - /// Called after a route is mounted. - async fn on_post_route(&mut self, _: &HookContext) {} + /// The router calls this hook after the handler completes, before + /// response serialisation. + async fn on_post_route(&self, _: &HookContext) {} } diff --git a/src/prelude.rs b/src/prelude.rs index 5953f69..a13b23d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -2,5 +2,5 @@ pub use crate::{ context, module::{AsyncModule, Module}, response::Response, - router::Router, + router::{Router, Server}, }; diff --git a/src/response.rs b/src/response.rs index d2223be..350e788 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,7 +1,4 @@ -//! Content and response handlers - -#[cfg(feature = "response-macros")] -mod macros; +//! This module provides the response type returned by handlers. macro_rules! response { ($name:ident, $status:expr) => { @@ -12,18 +9,25 @@ macro_rules! response { }; } -/// The content and response type a handler should reply with. +/// A response holds the status and content returned by a handler. +/// +/// The server replaces responses whose status or metadata cannot be encoded +/// with a temporary failure. Text bodies use LF line endings, including a +/// final newline after a non-empty footer. Binary bodies are sent unchanged. #[derive(Clone)] #[non_exhaustive] pub struct Response { - pub status: i32, - pub mime: Option, - pub content: String, - /// Raw body for status `21`/`22`; the router emits these bytes verbatim - /// instead of `content`. - pub binary_content: Option>, - pub character_set: Option, - pub languages: Option>, + status: i32, + payload: Payload, + mime: Option, + character_set: Option, + languages: Option>, +} + +#[derive(Clone)] +enum Payload { + Text(String), + Binary(Vec), } impl Response { @@ -73,7 +77,7 @@ impl Response { ) -> Self { let mut response = Self::new(21, String::new()); - response.binary_content = Some(content.as_ref().to_vec()); + response.payload = Payload::Binary(content.as_ref().to_vec()); response.with_mime(mime); @@ -87,7 +91,7 @@ impl Response { response.with_mime(tree_magic_mini::from_u8(content)); - response.binary_content = Some(content.to_vec()); + response.payload = Payload::Binary(content.to_vec()); response } @@ -97,8 +101,11 @@ impl Response { Self { status, mime: None, - content: content.into(), - binary_content: None, + payload: if matches!(status, 21 | 22) { + Payload::Binary(Vec::new()) + } else { + Payload::Text(content.into()) + }, character_set: None, languages: None, } @@ -107,24 +114,137 @@ impl Response { #[doc(hidden)] #[must_use] pub fn serialize_body(self, header: &str, footer: &str) -> Vec { - match self.status { - 20 => { - let mut body = Vec::with_capacity( - header.len() + self.content.len() + footer.len() + 1, - ); - - body.extend_from_slice(header.as_bytes()); - body.extend_from_slice(self.content.as_bytes()); + if let Payload::Binary(bytes) = self.payload { + return bytes; + } + + let mut body = Vec::with_capacity(self.body_capacity(header, footer)); + + self.append_body(&mut body, header, footer); + + body + } + + pub(crate) const fn body_capacity( + &self, + header: &str, + footer: &str, + ) -> usize { + match &self.payload { + Payload::Text(content) if self.status == 20 => + header.len() + content.len() + footer.len() + 2, + Payload::Binary(bytes) => bytes.len(), + Payload::Text(_) => 0, + } + } + + pub(crate) fn append_body( + &self, + body: &mut Vec, + header: &str, + footer: &str, + ) { + match &self.payload { + Payload::Text(content) if self.status == 20 => { + let text = self.mime.as_deref().is_none_or(|mime| { + mime + .split_once('/') + .is_some_and(|(kind, _)| kind.eq_ignore_ascii_case("text")) + }); + + if text { + append_text(body, header); + append_text(body, content); + } else { + body.extend_from_slice(header.as_bytes()); + body.extend_from_slice(content.as_bytes()); + } + body.push(b'\n'); - body.extend_from_slice(footer.as_bytes()); - body + if text { + append_text(body, footer); + + if body.last() != Some(&b'\n') { + body.push(b'\n'); + } + } else { + body.extend_from_slice(footer.as_bytes()); + } } - 21 | 22 => self.binary_content.unwrap_or_default(), - _ => Vec::new(), + Payload::Binary(bytes) => body.extend_from_slice(bytes), + Payload::Text(_) => {} + } + } + + /// Return the status supplied to the constructor, including internal binary + /// statuses 21 and 22. Replace the response to change its status or payload + /// kind. + #[must_use] + pub const fn status(&self) -> i32 { self.status } + + /// Return text content or header metadata, or `None` for binary responses. + #[must_use] + pub fn content(&self) -> Option<&str> { + match &self.payload { + Payload::Text(content) => Some(content), + Payload::Binary(_) => None, + } + } + + /// Borrow text content or header metadata for editing. + pub const fn content_mut(&mut self) -> Option<&mut String> { + match &mut self.payload { + Payload::Text(content) => Some(content), + Payload::Binary(_) => None, } } + /// Return the binary payload, or `None` for text and metadata responses. + #[must_use] + pub fn binary_content(&self) -> Option<&[u8]> { + match &self.payload { + Payload::Binary(bytes) => Some(bytes), + Payload::Text(_) => None, + } + } + + /// Borrow the binary payload for editing or replacing its owned buffer. + pub const fn binary_content_mut(&mut self) -> Option<&mut Vec> { + match &mut self.payload { + Payload::Binary(bytes) => Some(bytes), + Payload::Text(_) => None, + } + } + + /// Return the response's media type override. + #[must_use] + pub fn mime(&self) -> Option<&str> { self.mime.as_deref() } + + /// Borrow the media type override; set it to `None` to restore the default. + pub const fn mime_mut(&mut self) -> &mut Option { &mut self.mime } + + /// Return the response's character set override. + #[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. + pub const fn character_set_mut(&mut self) -> &mut Option { + &mut self.character_set + } + + /// Return the response's language override, including an explicitly empty + /// list. + #[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. + pub const fn languages_mut(&mut self) -> &mut Option> { + &mut self.languages + } + pub fn with_mime( &mut self, mime: impl Into + AsRef, @@ -157,6 +277,18 @@ impl Response { } } +fn append_text(body: &mut Vec, mut text: &str) { + while let Some(position) = text.find('\r') { + body.extend_from_slice(&text.as_bytes()[..position]); + body.push(b'\n'); + + text = &text[position + 1..]; + text = text.strip_prefix('\n').unwrap_or(text); + } + + body.extend_from_slice(text.as_bytes()); +} + impl std::future::IntoFuture for Response { type IntoFuture = std::future::Ready; type Output = Self; diff --git a/src/response/macros.rs b/src/response/macros.rs deleted file mode 100644 index 5a86eb1..0000000 --- a/src/response/macros.rs +++ /dev/null @@ -1,119 +0,0 @@ -macro_rules! sync_response { - ($($name:ident),*) => { - $( - /// Trailing commas are not supported at the moment! - #[macro_export] - macro_rules! $name { - ($body:expr /* $(,)? */) => { - |_: $crate::context::RouteContext| $crate::response::Response::$name($body) - }; - ($context:ident, $body:expr /* $(,)? */) => { - |$context: $crate::context::RouteContext| $crate::response::Response::$name($body) - }; - } - )* - }; -} - -macro_rules! async_response { - ($($name:ident),*) => { - $(::paste::paste! { - /// Trailing commas are not supported at the moment! - #[macro_export] - macro_rules! [< $name _async >] { - ($body:expr /* $(,)? */) => { - |_: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } - }; - ($context:ident, $body:expr /* $(,)? */) => { - |$context: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } - }; - } - })* - }; -} - -macro_rules! response { - ($($name:ident),* $(,)?) => { - $( - sync_response!($name); - async_response!($name); - )* - }; -} - -response!( - input, - sensitive_input, - success, - temporary_redirect, - permanent_redirect, - temporary_failure, - server_unavailable, - cgi_error, - proxy_error, - slow_down, - permanent_failure, - not_found, - gone, - proxy_refused, - bad_request, - client_certificate_required, - certificate_not_authorised, - certificate_not_valid, -); - -#[cfg(feature = "auto-deduce-mime")] -response!(binary_success_auto); - -/// Trailing commas are not supported at the moment! -#[macro_export] -macro_rules! binary_success { - ($body:expr, $mime:expr) => { - |_: $crate::context::RouteContext| { - $crate::response::Response::binary_success($body, $mime) - } - }; - ($body:expr) => {{ - #[cfg(not(feature = "auto-deduce-mime"))] - compile_error!( - "`binary_success` without a MIME type requires the `auto-deduce-mime` \ - feature to be enabled" - ); - - |_: $crate::context::RouteContext| { - #[cfg(feature = "auto-deduce-mime")] - return $crate::response::Response::binary_success_auto($body); - - // Suppress item not found warning - #[cfg(not(feature = "auto-deduce-mime"))] - $crate::response::Response::binary_success( - $body, - "application/octet-stream", - ) - } - }}; - ($context:ident, $body:expr, $mime:expr) => { - |$context: $crate::context::RouteContext| { - $crate::response::Response::binary_success($body, $mime) - } - }; - ($context:ident, $body:expr) => {{ - #[cfg(not(feature = "auto-deduce-mime"))] - compile_error!( - "`binary_success` without a MIME type requires the `auto-deduce-mime` \ - feature to be enabled" - ); - - |$context: $crate::context::RouteContext| { - #[cfg(feature = "auto-deduce-mime")] - return $crate::response::Response::binary_success_auto($body); - - // Suppress item not found warning - #[cfg(not(feature = "auto-deduce-mime"))] - $crate::response::Response::binary_success( - $body, - "application/octet-stream", - ) - } - }}; -} diff --git a/src/router.rs b/src/router.rs index ad9526b..75442c0 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,113 +1,123 @@ #![allow(clippy::significant_drop_tightening)] +use crate::{ + context::{ErrorContext, HookContext, RouteContext}, + handler::{ErrorResponse, Partial, PostRouteHook, PreRouteHook}, + module::{AsyncModule, Module}, + response::Response, + router_option::RouterOption, +}; +#[cfg(feature = "async-std")] +use async_std::{ + io::{ReadExt, WriteExt}, + sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}, +}; +use openssl::ssl::{self, SslAcceptor, SslMethod}; use std::{ collections::HashSet, error::Error, fmt::Write, future::IntoFuture, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + Mutex, + RwLock, + }, time, }; - -#[cfg(feature = "async-std")] -use async_std::{ - io::{ReadExt, WriteExt}, - sync::Mutex as AsyncMutex, -}; -use openssl::ssl::{self, SslAcceptor, SslMethod}; #[cfg(feature = "tokio")] use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - sync::Mutex as AsyncMutex, + sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}, }; -use url::Url; -use crate::{ - context::{ErrorContext, HookContext, RouteContext}, - handler::{ - ErrorResponse, - Partial, - PostRouteHook, - PreRouteHook, - RouteResponse, - }, - module::{AsyncModule, Module}, - response::Response, - router_option::RouterOption, -}; +mod connections; +mod request; +mod routes; +mod server; -macro_rules! block { - ($body:expr) => { - #[cfg(feature = "tokio")] - ::tokio::task::block_in_place(|| { - ::tokio::runtime::Handle::current().block_on(async { $body }); - }); - #[cfg(feature = "async-std")] - ::async_std::task::block_on(async { $body }); - }; -} +pub use connections::ConnectionLimits; +use connections::{with_timeout, TcpListener}; +use routes::Routes; +pub use server::Server; -macro_rules! or_error { - ($stream:ident, $operation:expr, $error_format:literal) => { - match $operation { - Ok(u) => u, - Err(e) => { - $stream - .write_all(format!($error_format, e).as_bytes()) - .await?; +const MAX_REQUEST_URI_BYTES: usize = 1024; - return Ok(()); - } - } - }; -} +#[cfg(feature = "tokio")] +type TcpStream = tokio::net::TcpStream; +#[cfg(feature = "async-std")] +type TcpStream = async_std::net::TcpStream; #[cfg(feature = "tokio")] -type Stream = tokio_openssl::SslStream; +type Stream = tokio_openssl::SslStream; #[cfg(feature = "async-std")] -type Stream = async_std_openssl::SslStream; +type Stream = async_std_openssl::SslStream; + +struct ModuleEntry { + module: Box, + poisoned: AtomicBool, +} -/// A router which takes care of all tasks a Windmark server should handle: -/// response generation, panics, logging, and more. +type SharedModule = Arc; +type SharedAsyncModule = Arc; + +#[derive(Default)] +struct ModuleScheduling { + concurrent: AtomicBool, + synchronous: RwLock<()>, + asynchronous: AsyncRwLock<()>, +} + +#[derive(Clone, Copy)] +enum HookPhase { + PreRoute, + PostRoute, +} + +/// A router dispatches requests through its handlers, partials, and modules. +/// +/// Requests must contain an absolute URI of at most 1024 bytes, excluding +/// CRLF, without userinfo or a fragment. Non-ASCII characters must be +/// percent-encoded. Other schemes remain available to proxy handlers. #[derive(Clone)] pub struct Router { - routes: matchit::Router>, + routes: Routes, error_handler: Arc, private_key_file_name: String, private_key_content: Option, certificate_file_name: String, certificate_content: Option, - headers: Arc>>>, - footers: Arc>>>, - ssl_acceptor: Arc, - manual_acceptor_set: bool, - #[cfg(feature = "logger")] - default_logger: bool, - #[cfg(feature = "logger")] - log_filter: String, + headers: Arc>>>, + footers: Arc>>>, + ssl_acceptor: Option>, pre_route_callback: Arc, post_route_callback: Arc, character_set: String, languages: Vec, port: u16, - async_modules: Arc>>>, - modules: Arc>>>, + async_modules: Arc>>, + modules: Arc>>, + module_scheduling: Arc, options: HashSet, listener_address: String, + connection_limits: ConnectionLimits, } struct RequestHandler { - routes: matchit::Router>, + routes: Routes, error_handler: Arc, - headers: Arc<[Box]>, - footers: Arc<[Box]>, + headers: Arc<[Arc]>, + footers: Arc<[Arc]>, pre_route_callback: Arc, post_route_callback: Arc, character_set: String, languages_joined: String, - async_modules: Arc>>>, - modules: Arc>>>, + async_modules: Arc>>, + modules: Arc>>, + module_scheduling: Arc, options: HashSet, + connection_limits: ConnectionLimits, } impl RequestHandler { @@ -117,90 +127,109 @@ impl RequestHandler { clippy::cognitive_complexity )] async fn handle(&self, stream: &mut Stream) -> Result<(), Box> { - let mut buffer = [0u8; 1024]; + let mut buffer = [0u8; MAX_REQUEST_URI_BYTES]; let mut footer = String::new(); let mut header = String::new(); - let mut request = String::new(); + let mut request = Vec::new(); + let request = with_timeout(self.connection_limits.request_timeout, async { + let url = loop { + let size = match stream.read(&mut buffer).await { + Ok(0) => return Ok(None), + Err(error) => return Err(error), + Ok(size) => size, + }; + + request.extend_from_slice(&buffer[..size]); + + let request_end = request.windows(2).position(|pair| pair == b"\r\n"); + let uri_length = request_end.unwrap_or_else(|| { + request.len() - usize::from(request.last() == Some(&b'\r')) + }); + + if uri_length > MAX_REQUEST_URI_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request URI exceeds 1024 bytes", + )); + } + + if let Some(position) = request_end { + let request = + std::str::from_utf8(&request[..position]).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + })?; - let mut url = loop { - let size = match stream.read(&mut buffer).await { - Ok(0) | Err(_) => return Ok(()), - Ok(size) => size, + break request::parse_uri(request).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + })?; + } }; - request.push_str(or_error!( - stream, - std::str::from_utf8(&buffer[0..size]), - "59 The server (Windmark) received a bad request: {}" - )); - - if request.len() > 1024 { - stream - .write_all( - b"59 The server (Windmark) received a request exceeding 1024 \ - bytes\r\n", - ) - .await?; + Ok(Some(url)) + }) + .await?; + let mut url = match request { + Ok(Some(url)) => url, + Ok(None) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + let response = format!( + "59 The server (Windmark) received a bad request: {error}\r\n" + ); - return Ok(()); - } + with_timeout( + self.connection_limits.write_timeout, + stream.write_all(response.as_bytes()), + ) + .await??; - if let Some(position) = request.find("\r\n") { - break or_error!( - stream, - Url::parse(&request[..position]), - "59 The server (Windmark) received a bad request: {}" - ); + return Ok(()); } + Err(error) => return Err(error.into()), }; if url.path().is_empty() { url.set_path("/"); } - let route_path = resolve_lookup_path(&self.options, url.path(), |candidate| { - self.routes.at(candidate).is_ok() - }); + let route_path = + resolve_lookup_path(&self.options, url.path(), |candidate| { + self.routes.contains(candidate) + }); let route = self.routes.at(&route_path); - let peer_certificate = stream.ssl().peer_certificate(); - let hook_context = HookContext::new( - stream.get_ref().peer_addr(), - url.clone(), - route.as_ref().ok().map(|route| route.params.clone()), - peer_certificate.clone(), - ); - for module in &mut *self.async_modules.lock().await { - module.on_pre_route(&hook_context).await; - } - - if let Ok(mut modules) = self.modules.lock() { - for module in &mut *modules { - module.on_pre_route(&hook_context); - } - } + let hook_context = HookContext { + peer_address: stream.get_ref().peer_addr().ok(), + url: url.clone(), + parameters: route.as_ref().ok().map(|route| route.parameters.clone()), + certificate: peer_certificate.clone(), + }; + call_async_modules( + &self.async_modules, + &self.module_scheduling, + &hook_context, + HookPhase::PreRoute, + ) + .await; + call_modules(&self.modules, &self.module_scheduling, |module| { + module.on_pre_route(&hook_context); + }); self.pre_route_callback.call(&hook_context); let mut content = if let Ok(ref route) = route { - let route_context = RouteContext::new( - stream.get_ref().peer_addr(), + let route_context = RouteContext { + peer_address: stream.get_ref().peer_addr().ok(), url, - &route.params, - peer_certificate, - ); + parameters: route.parameters.clone(), + certificate: peer_certificate, + }; for partial_header in self.headers.iter() { writeln!(&mut header, "{}", partial_header.call(&route_context)) .expect("failed to write header"); } - footer = self - .footers - .iter() - .map(|partial_footer| partial_footer.call(&route_context)) - .collect::>() - .join("\n"); + footer = render_footer(&self.footers, &route_context); route.value.call(route_context).await } else { @@ -214,73 +243,240 @@ impl RequestHandler { .await }; - for module in &mut *self.async_modules.lock().await { - module.on_post_route(&hook_context).await; + call_async_modules( + &self.async_modules, + &self.module_scheduling, + &hook_context, + HookPhase::PostRoute, + ) + .await; + call_modules(&self.modules, &self.module_scheduling, |module| { + module.on_post_route(&hook_context); + }); + self.post_route_callback.call(&hook_context, &mut content); + + let status_line = match status_line( + &content, + &self.character_set, + &self.languages_joined, + ) { + Ok(line) => line, + Err(error) => { + error!("response encoding error: {error}"); + + content = Response::temporary_failure( + "The server could not encode the response", + ); + + "40 The server could not encode the response".to_owned() + } + }; + let response = serialize_response(content, &status_line, &header, &footer); + + with_timeout( + self.connection_limits.write_timeout, + stream.write_all(&response), + ) + .await??; + + Ok(()) + } +} + +fn call_modules( + modules: &Mutex>, + scheduling: &ModuleScheduling, + hook: impl Fn(&dyn Module), +) { + let invoke = || { + let modules = modules.lock().ok().map(|modules| modules.clone()); + + if let Some(modules) = modules { + for module in modules { + if !module.poisoned.load(Ordering::Acquire) { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + hook(module.module.as_ref()); + })); + + if let Err(panic) = result { + module.poisoned.store(true, Ordering::Release); + std::panic::resume_unwind(panic); + } + } + } } + }; + + if scheduling.concurrent.load(Ordering::Relaxed) { + let _permit = scheduling + .synchronous + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + invoke(); + } else { + let _permit = scheduling + .synchronous + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + invoke(); + } +} - if let Ok(mut modules) = self.modules.lock() { - for module in &mut *modules { - module.on_post_route(&hook_context); +async fn call_async_modules( + modules: &AsyncMutex>, + scheduling: &ModuleScheduling, + context: &HookContext, + phase: HookPhase, +) { + let invoke = async { + let modules = modules.lock().await.clone(); + + for module in modules { + match phase { + HookPhase::PreRoute => module.on_pre_route(context).await, + HookPhase::PostRoute => module.on_post_route(context).await, } } + }; - self.post_route_callback.call(&hook_context, &mut content); + if scheduling.concurrent.load(Ordering::Relaxed) { + let _permit = scheduling.asynchronous.read().await; + + invoke.await; + } else { + let _permit = scheduling.asynchronous.write().await; - let status_code = if content.status == 21 || content.status == 22 { - 20 + invoke.await; + } +} + +fn serialize_response( + content: Response, + status_line: &str, + header: &str, + footer: &str, +) -> Vec { + let mut response = Vec::with_capacity( + status_line.len() + content.body_capacity(header, footer) + 2, + ); + + response.extend_from_slice(status_line.as_bytes()); + response.extend_from_slice(b"\r\n"); + content.append_body(&mut response, header, footer); + drop(content); + + response +} + +fn render_footer( + partials: &[Arc], + context: &RouteContext, +) -> String { + let mut footer = String::new(); + + for (index, partial) in partials.iter().enumerate() { + if index != 0 { + footer.push('\n'); + } + + footer.push_str(&partial.call(context)); + } + + footer +} + +/// Build a protocol-valid response header from the handler's response. +fn status_line( + content: &Response, + default_character_set: &str, + default_languages: &str, +) -> Result { + let status = content.status(); + + if matches!(status, 20..=22) { + let mime = content.mime().unwrap_or(if status == 20 { + "text/gemini" } else { - content.status - }; - let status_line = match content.status { - 20 => { - let mime = content.mime.as_deref().unwrap_or("text/gemini"); - let charset = content - .character_set - .as_deref() - .unwrap_or(&self.character_set); - let lang = content.languages.as_ref().map_or_else( - || std::borrow::Cow::Borrowed(self.languages_joined.as_str()), - |l| std::borrow::Cow::Owned(l.join(",")), - ); + "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"); + } - format!("{status_code} {mime}; charset={charset}; lang={lang}") - } - 21 => { - format!( - "{} {}", - status_code, - content.mime.as_deref().unwrap_or_default() - ) - } - #[cfg(feature = "auto-deduce-mime")] - 22 => { - format!( - "{} {}", - status_code, - content.mime.as_deref().unwrap_or_default() - ) - } - _ => { - format!( - "{} {}", - status_code, - content.content.lines().next().unwrap_or_default() - ) + write!(&mut metadata, "; lang={}", mime_parameter(&languages)) + .expect("writing to a string cannot fail"); } + + metadata + } else { + mime.to_owned() }; - let body = content.serialize_body(&header, &footer); - let mut response = Vec::with_capacity(status_line.len() + body.len() + 2); - - response.extend_from_slice(status_line.as_bytes()); - response.extend_from_slice(b"\r\n"); - response.extend_from_slice(&body); - stream.write_all(&response).await?; - #[cfg(feature = "tokio")] - stream.shutdown().await?; - #[cfg(feature = "async-std")] - stream.get_mut().shutdown(std::net::Shutdown::Both)?; - Ok(()) + if metadata.chars().any(char::is_control) + || metadata.parse::().is_err() + { + return Err("invalid response media type or parameters"); + } + + return Ok(format!("20 {metadata}")); + } + + if !matches!(status, 10 | 11 | 30 | 31 | 40..=44 | 50..=53 | 59..=62) { + return Err("undefined response status"); + } + + let metadata = content + .content() + .unwrap_or_default() + .lines() + .next() + .unwrap_or_default(); + + if metadata.chars().any(char::is_control) { + return Err("response metadata contains a control character"); + } + + if matches!(status, 10 | 11 | 30 | 31) && metadata.is_empty() { + return Err("input prompts and redirect targets must not be empty"); + } + + if matches!(status, 30 | 31) && !request::valid_uri_reference(metadata) { + return Err("invalid redirect URI reference"); + } + + if metadata.is_empty() { + Ok(status.to_string()) + } else { + Ok(format!("{status} {metadata}")) + } +} + +fn mime_parameter(value: &str) -> String { + if !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_graphic() && !b"()<>@,;:\\\"/[]?=".contains(&byte) + }) + { + value.to_owned() + } else { + format!("\"{}\"", value.replace('\\', "\\\\").replace('\"', "\\\"")) } } @@ -295,11 +491,7 @@ fn resolve_lookup_path( request_path: &str, route_exists: impl Fn(&str) -> bool, ) -> String { - let mut path = request_path.to_string(); - - if options.contains(&RouterOption::AllowCaseInsensitiveLookup) { - path = path.to_lowercase(); - } + let path = request_path.to_owned(); if route_exists(&path) { return path; @@ -328,21 +520,17 @@ fn resolve_lookup_path( } impl Router { - /// Create a new `Router` + /// Create a new `Router`. /// /// # Examples /// /// ```rust /// windmark::router::Router::new(); /// ``` - /// - /// # Panics - /// - /// if a default `SslAcceptor` could not be built. #[must_use] pub fn new() -> Self { Self::default() } - /// Set the filename of the private key file. + /// Set the filename of the private key file, replacing any inline key. /// /// # Examples /// @@ -354,11 +542,12 @@ impl Router { private_key_file_name: impl Into + AsRef, ) -> &mut Self { self.private_key_file_name = private_key_file_name.into(); + self.private_key_content = None; self } - /// Set the content of the private key + /// Set the content of the private key. /// /// # Examples /// @@ -374,7 +563,8 @@ impl Router { self } - /// Set the filename of the certificate chain file. + /// Set the filename of the certificate chain file, replacing any inline + /// chain. /// /// # Examples /// @@ -386,11 +576,12 @@ impl Router { certificate_name: impl Into + AsRef, ) -> &mut Self { self.certificate_file_name = certificate_name.into(); + self.certificate_content = None; self } - /// Set the content of the certificate chain file. + /// Set a PEM certificate chain, with the server certificate first. /// /// # Examples /// @@ -406,9 +597,9 @@ impl Router { self } - /// Map routes to URL paths + /// Map routes to URL paths. /// - /// Supports both synchronous and asynchronous handlers + /// This method supports both synchronous and asynchronous handlers. /// /// # Examples /// @@ -424,7 +615,8 @@ impl Router { /// /// # Panics /// - /// May panic if the route cannot be mounted. + /// This method panics if the route is invalid or conflicts with a mounted + /// route, including conflicts under case-insensitive matching when enabled. pub fn mount( &mut self, route: impl Into + AsRef, @@ -445,7 +637,8 @@ impl Router { self } - /// Create an error handler which will be displayed on any error. + /// Set the handler for unmatched routes. Transport errors and route panics + /// do not invoke this handler. /// /// # Examples /// @@ -485,7 +678,7 @@ impl Router { /// ``` pub fn add_header(&mut self, handler: impl Partial + 'static) -> &mut Self { (*self.headers.lock().expect("headers lock poisoned")) - .push(Box::new(handler)); + .push(Arc::new(handler)); self } @@ -494,7 +687,7 @@ impl Router { /// /// # Panics /// - /// May panic if the header cannot be added. + /// This method panics if the footer registry lock has been poisoned. /// /// # Examples /// @@ -507,12 +700,19 @@ impl Router { /// ``` pub fn add_footer(&mut self, handler: impl Partial + 'static) -> &mut Self { (*self.footers.lock().expect("footers lock poisoned")) - .push(Box::new(handler)); + .push(Arc::new(handler)); self } - /// Run the `Router` and wait for requests + /// Run the `Router` and wait for requests. + /// + /// Under the default Tokio runtime, the server runs until it receives an + /// interrupt (Ctrl+C / SIGINT), at which point it stops accepting new + /// connections and drains for the configured period before returning. The + /// default drain period is zero. The `async-std` runtime runs until the + /// process is terminated; use `run_until` for application-controlled + /// shutdown. /// /// # Examples /// @@ -522,39 +722,92 @@ impl Router { /// /// # Panics /// - /// if the client could not be accepted. + /// This method panics if a partial registry lock has been poisoned. /// /// # Errors /// - /// if the `TcpListener` could not be bound. + /// This method returns an error if TLS configuration or listener binding + /// fails. pub async fn run(&mut self) -> Result<(), Box> { - if !self.manual_acceptor_set { - self.create_acceptor()?; - } + let server = self.bind().await?; - #[cfg(feature = "logger")] - if self.default_logger { - pretty_env_logger::formatted_builder() - .parse_filters(&self.log_filter) - .init(); - } + server.run().await; - #[cfg(feature = "tokio")] - let listener = tokio::net::TcpListener::bind(format!( - "{}:{}", - self.listener_address, self.port - )) - .await?; - #[cfg(feature = "async-std")] - let listener = async_std::net::TcpListener::bind(format!( - "{}:{}", - self.listener_address, self.port - )) - .await?; + Ok(()) + } + + /// Set connection limits for subsequent server starts. + /// + /// # Examples + /// + /// ```rust + /// let mut limits = windmark::router::ConnectionLimits::default(); + /// + /// limits.max_connections = std::num::NonZeroUsize::new(256); + /// limits.request_timeout = Some(std::time::Duration::from_secs(30)); + /// limits.drain_timeout = std::time::Duration::from_secs(5); + /// + /// windmark::router::Router::new().set_connection_limits(limits); + /// ``` + pub const fn set_connection_limits( + &mut self, + limits: ConnectionLimits, + ) -> &mut Self { + self.connection_limits = limits; + + self + } + + /// Serve requests until `shutdown` completes, then drain active connections + /// for the configured period. Remaining handler futures are cancelled. + /// Dropping this future also cancels the server's connection tasks. + /// + /// # Errors + /// + /// This method returns an error if TLS configuration or listener binding + /// fails. + /// + /// # Panics + /// + /// This method panics if a partial registry lock has been poisoned. + pub async fn run_until( + &mut self, + shutdown: impl std::future::Future, + ) -> Result<(), Box> { + let server = self.bind().await?; - #[cfg(feature = "logger")] - info!("windmark is listening for connections"); + server.run_until(shutdown).await; + Ok(()) + } + + /// Bind a server using the current configuration. + /// + /// Routes, callbacks, partials, TLS settings, and connection limits are + /// captured at binding. Module registrations and module scheduling remain + /// shared with this router and its clones. State owned by handlers and + /// partials also retains its existing sharing semantics. + /// + /// The returned server owns the listener independently of this router. + /// Set the port to zero and call `Server::local_addr` to discover an + /// operating-system-assigned port before serving requests. + /// + /// # Errors + /// + /// This method returns an error if TLS configuration or listener binding + /// fails. + /// + /// # Panics + /// + /// This method panics if a partial registry lock has been poisoned. + pub async fn bind(&self) -> Result> { + let acceptor = match &self.ssl_acceptor { + Some(acceptor) => acceptor.clone(), + None => Arc::new(self.create_acceptor()?), + }; + let listener = + TcpListener::bind(format!("{}:{}", self.listener_address, self.port)) + .await?; let handler = Arc::new(RequestHandler { routes: self.routes.clone(), error_handler: self.error_handler.clone(), @@ -562,15 +815,13 @@ impl Router { .headers .lock() .expect("headers lock poisoned") - .drain(..) - .collect::>() + .clone() .into(), footers: self .footers .lock() .expect("footers lock poisoned") - .drain(..) - .collect::>() + .clone() .into(), pre_route_callback: self.pre_route_callback.clone(), post_route_callback: self.post_route_callback.clone(), @@ -578,67 +829,38 @@ impl Router { languages_joined: self.languages.join(","), async_modules: self.async_modules.clone(), modules: self.modules.clone(), + module_scheduling: self.module_scheduling.clone(), options: self.options.clone(), + connection_limits: self.connection_limits, }); - loop { - match listener.accept().await { - Ok((stream, _)) => { - let handler = Arc::clone(&handler); - let acceptor = self.ssl_acceptor.clone(); - #[cfg(feature = "tokio")] - let spawner = tokio::spawn; - #[cfg(feature = "async-std")] - let spawner = async_std::task::spawn; - - spawner(async move { - let ssl = match ssl::Ssl::new(acceptor.context()) { - Ok(ssl) => ssl, - Err(e) => { - error!("ssl context error: {e:?}"); - - return; - } - }; - - #[cfg(feature = "tokio")] - let quick_stream = tokio_openssl::SslStream::new(ssl, stream); - #[cfg(feature = "async-std")] - let quick_stream = async_std_openssl::SslStream::new(ssl, stream); - - match quick_stream { - Ok(mut stream) => { - if let Err(e) = std::pin::Pin::new(&mut stream).accept().await { - warn!("stream accept error: {e:?}"); - - return; - } - - if let Err(e) = handler.handle(&mut stream).await { - error!("handle error: {e}"); - } - } - Err(e) => error!("ssl stream error: {e:?}"), - } - }); - } - Err(e) => error!("tcp stream error: {e:?}"), - } - } + Ok(Server { + listener, + handler, + acceptor, + }) } - fn create_acceptor(&mut self) -> Result<(), Box> { + fn create_acceptor(&self) -> Result> { let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; - if let Some(ref cert_content) = self.certificate_content { - builder.set_certificate( - openssl::x509::X509::from_pem(cert_content.as_bytes())?.as_ref(), - )?; + if let Some(ref content) = self.certificate_content { + let mut certificates = + openssl::x509::X509::stack_from_pem(content.as_bytes())?.into_iter(); + let leaf = certificates.next().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the certificate chain is empty", + ) + })?; + + builder.set_certificate(&leaf)?; + + for certificate in certificates { + builder.add_extra_chain_cert(certificate)?; + } } else { - builder.set_certificate_file( - &self.certificate_file_name, - ssl::SslFiletype::PEM, - )?; + builder.set_certificate_chain_file(&self.certificate_file_name)?; } if let Some(ref key_content) = self.private_key_content { @@ -663,12 +885,10 @@ impl Router { .as_bytes(), )?; - self.ssl_acceptor = Arc::new(builder.build()); - - Ok(()) + Ok(builder.build()) } - /// Use a self-made `SslAcceptor` + /// Use a custom `SslAcceptor` instead of building one from the credentials. /// /// # Examples /// @@ -691,61 +911,13 @@ impl Router { /// }); /// ``` pub fn set_ssl_acceptor(&mut self, ssl_acceptor: SslAcceptor) -> &mut Self { - self.ssl_acceptor = Arc::new(ssl_acceptor); - self.manual_acceptor_set = true; + self.ssl_acceptor = Some(Arc::new(ssl_acceptor)); self } - /// Enabled the default logger (the - /// [`pretty_env_logger`](https://crates.io/crates/pretty_env_logger) and - /// [`log`](https://crates.io/crates/log) crates). - #[cfg(feature = "logger")] - pub fn enable_default_logger(&mut self, enable: bool) -> &mut Self { - self.default_logger = enable; - - if self.log_filter.is_empty() { - self.log_filter = "windmark=trace".to_string(); - } - - self - } - - /// Set the default logger's log level. - /// - /// If you enable Windmark's default logger with `enable_default_logger`, - /// Windmark will only log, logs from itself. By setting a log level with - /// this method, you are overriding the default log level, so you must choose - /// to enable logs from Windmark with the `log_windmark` parameter. - /// - /// Log level "language" is detailed - /// [here](https://docs.rs/env_logger/0.9.0/env_logger/#enabling-logging). - /// - /// # Examples - /// - /// ```rust - /// windmark::router::Router::new() - /// .enable_default_logger(true) - /// .set_log_level("your_crate_name=trace", true); - /// // If you would only like to log, logs from your crate: - /// // .set_log_level("your_crate_name=trace", false); - /// ``` - #[cfg(feature = "logger")] - pub fn set_log_level( - &mut self, - log_level: impl Into + AsRef, - log_windmark: bool, - ) -> &mut Self { - self.log_filter = format!( - "{}{}", - if log_windmark { "windmark," } else { "" }, - log_level.into() - ); - - self - } - - /// Set a callback to run before a client response is delivered + /// Set a callback to run before a request is dispatched, even if the request + /// does not match a route. /// /// # Examples /// @@ -770,7 +942,8 @@ impl Router { self } - /// Set a callback to run after a client response is delivered + /// Set a callback to modify the response after the handler, before + /// serialisation and delivery. /// /// # Examples /// @@ -846,13 +1019,13 @@ impl Router { self } - /// Attach a stateful module to a `Router`; with async support + /// Attach a stateful module and await its initialisation before registering + /// its request hooks. /// - /// Like a stateless module is an extension or middleware to a `Router`. - /// Modules get full access to the `Router` and can be extended by a third - /// party, but also, can create hooks will be executed through various parts - /// of a routes' lifecycle. Stateful modules also have state, so variables can - /// be stored for further access. + /// Like a stateless module, a stateful module extends a `Router` and gets + /// full access to it during attachment. Third parties can provide modules + /// with hooks that run during a request's lifecycle. Stateful modules retain + /// state between hook calls. /// /// # Panics /// @@ -866,7 +1039,7 @@ impl Router { /// /// #[derive(Default)] /// struct Clicker { - /// clicks: isize, + /// clicks: std::sync::atomic::AtomicUsize, /// } /// /// #[async_trait::async_trait] @@ -875,47 +1048,49 @@ impl Router { /// info!("clicker has been attached!"); /// } /// - /// async fn on_pre_route(&mut self, context: &HookContext) { - /// self.clicks += 1; + /// async fn on_pre_route(&self, context: &HookContext) { + /// let clicks = self + /// .clicks + /// .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + /// + 1; /// /// info!( /// "clicker has been called pre-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// clicks /// ); /// } /// - /// async fn on_post_route(&mut self, context: &HookContext) { + /// async fn on_post_route(&self, context: &HookContext) { /// info!( /// "clicker has been called post-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// self.clicks.load(std::sync::atomic::Ordering::Relaxed) /// ); /// } /// } /// - /// // Router::new().attach_async(Clicker::default()); + /// # #[windmark::main] + /// # async fn main() { + /// Router::new().attach_async(Clicker::default()).await; + /// # } /// ``` - pub fn attach_async( + pub async fn attach_async( &mut self, mut module: impl AsyncModule + 'static, ) -> &mut Self { - block!({ - module.on_attach(self).await; - - (*self.async_modules.lock().await).push(Box::new(module)); - }); + module.on_attach(self).await; + (*self.async_modules.lock().await).push(Arc::new(module)); self } /// Attach a stateful module to a `Router`. /// - /// Like a stateless module is an extension or middleware to a `Router`. - /// Modules get full access to the `Router` and can be extended by a third - /// party, but also, can create hooks will be executed through various parts - /// of a routes' lifecycle. Stateful modules also have state, so variables can - /// be stored for further access. + /// Like a stateless module, a stateful module extends a `Router` and gets + /// full access to it during attachment. Third parties can provide modules + /// with hooks that run during a request's lifecycle. Stateful modules retain + /// state between hook calls. /// /// # Panics /// @@ -929,7 +1104,7 @@ impl Router { /// /// #[derive(Default)] /// struct Clicker { - /// clicks: isize, + /// clicks: std::sync::atomic::AtomicUsize, /// } /// /// impl windmark::module::Module for Clicker { @@ -937,44 +1112,47 @@ impl Router { /// info!("clicker has been attached!"); /// } /// - /// fn on_pre_route(&mut self, context: &HookContext) { - /// self.clicks += 1; + /// fn on_pre_route(&self, context: &HookContext) { + /// let clicks = self + /// .clicks + /// .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + /// + 1; /// /// info!( /// "clicker has been called pre-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// clicks /// ); /// } /// - /// fn on_post_route(&mut self, context: &HookContext) { + /// fn on_post_route(&self, context: &HookContext) { /// info!( /// "clicker has been called post-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// self.clicks.load(std::sync::atomic::Ordering::Relaxed) /// ); /// } /// } /// /// Router::new().attach(Clicker::default()); /// ``` - pub fn attach( - &mut self, - mut module: impl Module + 'static + Send, - ) -> &mut Self { + pub fn attach(&mut self, mut module: impl Module + 'static) -> &mut Self { module.on_attach(self); - - (*self.modules.lock().expect("modules lock poisoned")) - .push(Box::new(module)); + (*self.modules.lock().expect("modules lock poisoned")).push(Arc::new( + ModuleEntry { + module: Box::new(module), + poisoned: AtomicBool::new(false), + }, + )); self } /// Specify a custom character set. /// - /// Will be over-ridden if a character set is specified in a [`Response`]. + /// A character set specified in a [`Response`] overrides this setting. /// - /// Defaults to `"utf-8"`. + /// The default character set is `"utf-8"`. /// /// # Examples /// @@ -992,9 +1170,9 @@ impl Router { /// Specify a custom language. /// - /// Will be over-ridden if a language is specified in a [`Response`]. + /// Languages specified in a [`Response`] override this setting. /// - /// Defaults to `"en"`. + /// The default language is `"en"`. /// /// # Examples /// @@ -1014,7 +1192,7 @@ impl Router { /// Specify a custom port. /// - /// Defaults to `1965`. + /// The default port is `1965`. /// /// # Examples /// @@ -1027,7 +1205,13 @@ impl Router { self } - /// Add optional features to the router + /// Add optional features to the router. + /// + /// # Panics + /// + /// This method panics if enabling case-insensitive matching would make + /// existing routes conflict. Case-insensitive matching remains disabled + /// when validation fails. /// /// # Examples /// @@ -1039,13 +1223,33 @@ impl Router { /// ``` pub fn add_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { - self.options.insert(*option); + if *option == RouterOption::AllowCaseInsensitiveLookup { + self + .routes + .enable_case_insensitive() + .expect("routes conflict under case-insensitive matching"); + } + + if *option == RouterOption::AllowConcurrentModules { + self + .module_scheduling + .concurrent + .store(true, Ordering::Relaxed); + } else { + self.options.insert(*option); + } } self } - /// Toggle optional features for the router + /// Toggle optional features for the router. + /// + /// # Panics + /// + /// This method panics if enabling case-insensitive matching would make + /// existing routes conflict. Case-insensitive matching remains disabled + /// when validation fails. /// /// # Examples /// @@ -1057,17 +1261,22 @@ impl Router { /// ``` pub fn toggle_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { - if self.options.contains(option) { - self.options.remove(option); + if *option == RouterOption::AllowConcurrentModules { + self + .module_scheduling + .concurrent + .fetch_xor(true, Ordering::Relaxed); + } else if self.options.contains(option) { + self.remove_options(std::slice::from_ref(option)); } else { - self.options.insert(*option); + self.add_options(std::slice::from_ref(option)); } } self } - /// Remove optional features from the router + /// Remove optional features from the router. /// /// # Examples /// @@ -1079,7 +1288,18 @@ impl Router { /// ``` pub fn remove_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { - self.options.remove(option); + if *option == RouterOption::AllowCaseInsensitiveLookup { + self.routes.disable_case_insensitive(); + } + + if *option == RouterOption::AllowConcurrentModules { + self + .module_scheduling + .concurrent + .store(false, Ordering::Relaxed); + } else { + self.options.remove(option); + } } self @@ -1087,7 +1307,7 @@ impl Router { /// Specify a custom listener address. /// - /// Defaults to `"0.0.0.0"`. + /// The default listener address is `"0.0.0.0"`. /// /// # Examples /// @@ -1107,8 +1327,8 @@ impl Router { impl Default for Router { fn default() -> Self { Self { - routes: matchit::Router::new(), - error_handler: Arc::new(|_| { + routes: Routes::default(), + error_handler: Arc::new(|_| { async { Response::not_found( "This capsule has not implemented an error handler...", @@ -1117,142 +1337,28 @@ impl Default for Router { }), private_key_file_name: String::new(), certificate_file_name: String::new(), - headers: Arc::new(Mutex::new(vec![])), - footers: Arc::new(Mutex::new(vec![])), - ssl_acceptor: Arc::new( - SslAcceptor::mozilla_intermediate(SslMethod::tls()) - .expect("failed to create default SSL acceptor") - .build(), - ), - manual_acceptor_set: false, - #[cfg(feature = "logger")] - default_logger: false, - #[cfg(feature = "logger")] - log_filter: String::new(), - pre_route_callback: Arc::new((|_| {}) as fn(&HookContext)), - post_route_callback: Arc::new( + headers: Arc::new(Mutex::new(vec![])), + footers: Arc::new(Mutex::new(vec![])), + ssl_acceptor: None, + pre_route_callback: Arc::new((|_| {}) as fn(&HookContext)), + post_route_callback: Arc::new( (|_, _: &mut Response| {}) as fn(&HookContext, &mut Response), ), - character_set: "utf-8".to_string(), - languages: vec!["en".to_string()], - port: 1965, - modules: Arc::new(Mutex::new(vec![])), - async_modules: Arc::new(AsyncMutex::new(vec![])), - options: HashSet::new(), - private_key_content: None, - certificate_content: None, - listener_address: "0.0.0.0".to_string(), + character_set: "utf-8".to_string(), + languages: vec!["en".to_string()], + port: 1965, + modules: Arc::new(Mutex::new(vec![])), + async_modules: Arc::new(AsyncMutex::new(vec![])), + module_scheduling: Arc::new(ModuleScheduling::default()), + options: HashSet::new(), + private_key_content: None, + certificate_content: None, + listener_address: "0.0.0.0".to_string(), + connection_limits: ConnectionLimits::default(), } } } #[cfg(test)] -mod tests { - use std::collections::HashSet; - - use matchit::Router as MatchRouter; - - use super::resolve_lookup_path; - use crate::router_option::RouterOption; - - /// Resolve `request_path` against a router holding `routes`, under `options`. - fn resolve( - options: &[RouterOption], - request_path: &str, - routes: &[&str], - ) -> String { - let mut router = MatchRouter::new(); - - for route in routes { - router.insert(*route, ()).unwrap(); - } - - let option_set = options.iter().copied().collect::>(); - - resolve_lookup_path(&option_set, request_path, |candidate| { - router.at(candidate).is_ok() - }) - } - - #[test] - fn exact_match_is_returned_unchanged() { - assert_eq!(resolve(&[], "/foo", &["/foo"]), "/foo"); - } - - #[test] - fn unmatched_path_without_options_is_returned_unchanged() { - assert_eq!(resolve(&[], "/missing", &["/foo"]), "/missing"); - } - - #[test] - fn dynamic_route_is_matched_without_modification() { - assert_eq!(resolve(&[], "/posts/42", &["/posts/:id"]), "/posts/42"); - } - - #[test] - fn case_insensitive_lookup_lowercases_the_path() { - assert_eq!( - resolve(&[RouterOption::AllowCaseInsensitiveLookup], "/FoO", &["/foo"]), - "/foo", - ); - } - - #[test] - fn extra_trailing_slash_is_removed_when_unslashed_route_exists() { - assert_eq!( - resolve(&[RouterOption::RemoveExtraTrailingSlash], "/foo/", &["/foo"]), - "/foo", - ); - } - - #[test] - fn root_path_is_left_untouched_by_trailing_slash_removal() { - assert_eq!( - resolve(&[RouterOption::RemoveExtraTrailingSlash], "/", &["/"]), - "/", - ); - } - - #[test] - fn missing_trailing_slash_is_added_when_slashed_route_exists() { - assert_eq!( - resolve(&[RouterOption::AddMissingTrailingSlash], "/foo", &["/foo/"]), - "/foo/", - ); - } - - #[test] - fn trailing_slash_fix_is_skipped_when_the_target_route_is_absent() { - assert_eq!( - resolve(&[RouterOption::RemoveExtraTrailingSlash], "/foo/", &["/bar"]), - "/foo/", - ); - } - - #[test] - fn exact_match_takes_precedence_over_trailing_slash_fix() { - assert_eq!( - resolve( - &[RouterOption::RemoveExtraTrailingSlash], - "/foo/", - &["/foo", "/foo/"], - ), - "/foo/", - ); - } - - #[test] - fn case_insensitive_lookup_combines_with_trailing_slash_removal() { - assert_eq!( - resolve( - &[ - RouterOption::AllowCaseInsensitiveLookup, - RouterOption::RemoveExtraTrailingSlash, - ], - "/Foo/", - &["/foo"], - ), - "/foo", - ); - } -} +#[path = "../tests/router.rs"] +mod tests; diff --git a/src/router/connections.rs b/src/router/connections.rs new file mode 100644 index 0000000..5fef9d9 --- /dev/null +++ b/src/router/connections.rs @@ -0,0 +1,207 @@ +use super::{RequestHandler, Stream, TcpStream}; +use futures_util::{ + future::{AbortHandle, Abortable, BoxFuture}, + stream::FuturesUnordered, + FutureExt, + StreamExt, +}; +use openssl::ssl::{Ssl, SslAcceptor}; +use std::{ + future::Future, + io, + num::NonZeroUsize, + panic::AssertUnwindSafe, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; +#[cfg(feature = "tokio")] +use tokio::io::AsyncWriteExt; + +#[cfg(feature = "tokio")] +pub(super) type TcpListener = tokio::net::TcpListener; +#[cfg(feature = "async-std")] +pub(super) type TcpListener = async_std::net::TcpListener; + +/// These limits apply to each server when it starts. +/// +/// Defaults preserve unlimited connections and stage durations. A zero drain +/// timeout cancels active connections immediately when shutdown is requested. +/// Cancellation drops unfinished handler futures; it cannot undo their effects +/// or interrupt synchronous code that blocks a runtime worker. +#[derive(Clone, Copy, Debug, Default)] +#[non_exhaustive] +pub struct ConnectionLimits { + /// This cap includes connections waiting for their TLS handshake. + pub max_connections: Option, + /// This deadline covers the complete TLS handshake. + pub handshake_timeout: Option, + /// This deadline covers the complete request, including its CRLF terminator. + pub request_timeout: Option, + /// This deadline covers writing a response to the client. + pub write_timeout: Option, + /// This deadline covers sending the TLS close notification. + pub shutdown_timeout: Option, + /// Active connections may finish during this period after acceptance stops. + pub drain_timeout: Duration, +} + +pub(super) async fn with_timeout( + duration: Option, + future: F, +) -> io::Result { + let Some(duration) = duration else { + return Ok(future.await); + }; + #[cfg(feature = "tokio")] + let result = tokio::time::timeout(duration, future).await; + #[cfg(feature = "async-std")] + let result = async_std::future::timeout(duration, future).await; + + result.map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "connection deadline elapsed") + }) +} + +struct ConnectionTask { + completion: BoxFuture<'static, ()>, + abort: AbortHandle, +} + +impl Future for ConnectionTask { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> { + self.completion.as_mut().poll(context) + } +} + +impl Drop for ConnectionTask { + fn drop(&mut self) { self.abort.abort(); } +} + +fn spawn_connection( + handler: Arc, + acceptor: Arc, + stream: TcpStream, +) -> ConnectionTask { + let (abort, registration) = AbortHandle::new_pair(); + let connection = async move { + let ssl = Ssl::new(acceptor.context())?; + let mut stream = Stream::new(ssl, stream)?; + let limits = handler.connection_limits; + + with_timeout(limits.handshake_timeout, Pin::new(&mut stream).accept()) + .await??; + + if let Err(error) = handler.handle(&mut stream).await { + error!("request handling error: {error}"); + + // An incomplete write must not look like a completed response to clients. + return Ok(()); + } + + #[cfg(feature = "tokio")] + let shutdown = stream.shutdown(); + #[cfg(feature = "async-std")] + let shutdown = std::future::poll_fn(|context| { + async_std::io::Write::poll_close(Pin::new(&mut stream), context) + }); + + with_timeout(limits.shutdown_timeout, shutdown).await??; + + Ok::<(), Box>(()) + }; + let connection = async move { + match AssertUnwindSafe(connection).catch_unwind().await { + Ok(Ok(())) => {} + Ok(Err(error)) => error!("connection error: {error}"), + Err(_) => error!("connection task panicked"), + } + }; + let connection = async move { + let _ = Abortable::new(connection, registration).await; + }; + #[cfg(feature = "tokio")] + let task = tokio::spawn(connection); + #[cfg(feature = "async-std")] + let task = async_std::task::spawn(connection); + let completion = async move { + #[cfg(feature = "tokio")] + if let Err(error) = task.await { + error!("connection task failed: {error}"); + } + + #[cfg(feature = "async-std")] + task.await; + } + .boxed(); + + ConnectionTask { + completion, + abort, + } +} + +pub(super) async fn serve( + listener: TcpListener, + handler: Arc, + acceptor: Arc, + shutdown: impl Future, +) { + let limits = handler.connection_limits; + let mut connections = FuturesUnordered::new(); + let shutdown = shutdown.fuse(); + + futures_util::pin_mut!(shutdown); + + loop { + let can_accept = limits + .max_connections + .is_none_or(|maximum| connections.len() < maximum.get()); + let event = { + let incoming = async { + if !can_accept { + std::future::pending::<()>().await; + } + + listener.accept().await + } + .fuse(); + let completed = async { + if connections.is_empty() { + std::future::pending::<()>().await; + } + + connections.next().await; + } + .fuse(); + + futures_util::pin_mut!(incoming, completed); + + futures_util::select_biased! { + () = shutdown => break, + () = completed => None, + connection = incoming => Some(connection), + } + }; + + if let Some(connection) = event { + match connection { + Ok((stream, _)) => + connections.push(spawn_connection( + handler.clone(), + acceptor.clone(), + stream, + )), + Err(error) => error!("TCP accept error: {error}"), + } + } + } + + drop(listener); + + let drain = async { while connections.next().await.is_some() {} }; + let _ = with_timeout(Some(limits.drain_timeout), drain).await; +} diff --git a/src/router/request.rs b/src/router/request.rs new file mode 100644 index 0000000..175f915 --- /dev/null +++ b/src/router/request.rs @@ -0,0 +1,80 @@ +use url::Url; + +pub(super) fn parse_uri(request: &str) -> Result { + if !valid_uri_characters(request, false) { + return Err("request URI contains invalid syntax".into()); + } + + if let Some((_, remainder)) = request.split_once(':') { + let path_and_query = if let Some(authority) = remainder.strip_prefix("//") { + let end = authority.find(['/', '?']).unwrap_or(authority.len()); + + if authority[..end].contains('@') { + return Err("request URI must not contain userinfo".into()); + } + + &authority[end..] + } else { + remainder + }; + + if path_and_query.contains(['[', ']']) { + return Err("request URI contains an unescaped bracket".into()); + } + } + + let url = Url::parse(request).map_err(|error| error.to_string())?; + + if url.scheme() == "gemini" && url.host_str().is_none_or(str::is_empty) { + return Err("Gemini request URI requires a host".into()); + } + + Ok(url) +} + +fn valid_uri_characters(request: &str, allow_fragment: bool) -> bool { + let bytes = request.as_bytes(); + let mut position = 0; + + while position < bytes.len() { + let byte = bytes[position]; + + if byte == b'%' { + if bytes + .get(position + 1..position + 3) + .is_none_or(|digits| !digits.iter().all(u8::is_ascii_hexdigit)) + { + return false; + } + + position += 3; + } else if byte.is_ascii_alphanumeric() + || b":/?[]@!$&'()*+,;=-._~".contains(&byte) + || (allow_fragment && byte == b'#') + { + position += 1; + } else { + return false; + } + } + + true +} + +pub(super) fn valid_uri_reference(reference: &str) -> bool { + 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())..] + }); + + 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() +} diff --git a/src/router/routes.rs b/src/router/routes.rs new file mode 100644 index 0000000..0fc1e78 --- /dev/null +++ b/src/router/routes.rs @@ -0,0 +1,97 @@ +use crate::{context::Parameters, handler::RouteResponse}; +use std::sync::Arc; + +#[derive(Clone, Default)] +pub(super) struct Routes { + exact: matchit::Router>, + folded: Option>>, + registrations: Vec<(String, Arc)>, +} + +pub(super) struct MatchedRoute<'router> { + pub value: &'router Arc, + pub parameters: Parameters, +} + +impl Routes { + pub fn insert( + &mut self, + path: String, + handler: Arc, + ) -> Result<(), matchit::InsertError> { + let mut folded = self.folded.clone(); + + if let Some(routes) = &mut folded { + routes.insert(fold_pattern(&path), handler.clone())?; + } + + self.exact.insert(path.clone(), handler.clone())?; + self.registrations.push((path, handler)); + + self.folded = folded; + + Ok(()) + } + + pub fn enable_case_insensitive( + &mut self, + ) -> Result<(), matchit::InsertError> { + if self.folded.is_some() { + return Ok(()); + } + + let mut routes = matchit::Router::new(); + + for (path, handler) in &self.registrations { + routes.insert(fold_pattern(path), handler.clone())?; + } + + self.folded = Some(routes); + + Ok(()) + } + + pub fn disable_case_insensitive(&mut self) { self.folded = None; } + + pub fn contains(&self, path: &str) -> bool { + self.folded.as_ref().map_or_else( + || self.exact.at(path).is_ok(), + |routes| routes.at(&path.to_ascii_lowercase()).is_ok(), + ) + } + + pub fn at( + &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), + }) + } + } +} + +fn fold_pattern(path: &str) -> String { + let mut folded = String::with_capacity(path.len()); + + for segment in path.split_inclusive('/') { + let parameter = segment.find([':', '*']).unwrap_or(segment.len()); + + folded.push_str(&segment[..parameter].to_ascii_lowercase()); + folded.push_str(&segment[parameter..]); + } + + folded +} diff --git a/src/router/server.rs b/src/router/server.rs new file mode 100644 index 0000000..11f9837 --- /dev/null +++ b/src/router/server.rs @@ -0,0 +1,48 @@ +use super::{connections, RequestHandler, TcpListener}; +use openssl::ssl::SslAcceptor; +use std::{future::Future, io, net::SocketAddr, sync::Arc}; + +/// A server owns a bound listener and the configuration used to serve it. +/// +/// Create a server with `Router::bind`. Running it consumes the server; +/// dropping it before it runs releases the listener. Dropping its running +/// future also cancels active connection tasks. +pub struct Server { + pub(super) listener: TcpListener, + pub(super) handler: Arc, + pub(super) acceptor: Arc, +} + +impl Server { + /// Return the listener's local address, including its assigned port. + /// + /// # Errors + /// + /// This method returns an error if the operating system cannot retrieve + /// the listener's address. + pub fn local_addr(&self) -> io::Result { + self.listener.local_addr() + } + + /// Serve requests until an interrupt arrives under Tokio, then drain + /// connections for the configured period. Under `async-std`, serve until + /// the process terminates; use `run_until` for controlled shutdown. + pub async fn run(self) { + #[cfg(feature = "tokio")] + let shutdown = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(feature = "async-std")] + let shutdown = std::future::pending(); + + self.run_until(shutdown).await; + } + + /// Serve requests until `shutdown` completes, then drain connections for + /// the configured period and cancel any remaining handler futures. + pub async fn run_until(self, shutdown: impl Future) { + info!("windmark is listening for connections"); + connections::serve(self.listener, self.handler, self.acceptor, shutdown) + .await; + } +} diff --git a/src/router_option.rs b/src/router_option.rs index a28ba6f..5bd56da 100644 --- a/src/router_option.rs +++ b/src/router_option.rs @@ -1,4 +1,4 @@ -/// Options that can be set for the `Router` +/// These options configure the `Router`. #[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] pub enum RouterOption { /// If enabled, removes a trailing slash from the request URL path if a route @@ -7,7 +7,18 @@ pub enum RouterOption { /// If enabled, adds a trailing slash to the request URL path if a route /// exists for the path with the slash (e.g., `/foo` becomes `/foo/`). AddMissingTrailingSlash, - /// If enabled, the router will perform case-insensitive matching for - /// incoming request URL paths (e.g., `/foo` will match `/Foo` or `/FOO`). + /// If enabled, the router ignores ASCII case in static route segments. + /// Parameter names and captured values retain their original spelling. + /// Routes that conflict under this matching rule cannot be registered + /// together while the option is enabled. AllowCaseInsensitiveLookup, + /// If enabled, module hooks may run concurrently across requests, including + /// multiple invocations of the same module. By default, each synchronous or + /// asynchronous hook phase runs exclusively across its module collection. + /// + /// This option is shared by cloned routers, including running servers. + /// Changes apply when a hook phase selects its scheduling mode. An exclusive + /// phase waits for any concurrent phases to finish before invoking hooks. + /// Poisoned synchronous modules are skipped in either mode. + AllowConcurrentModules, } diff --git a/src/utilities.rs b/src/utilities.rs index 8b4af15..1e8c78b 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -1,8 +1,9 @@ -//! Utilities to make cumbersome tasks simpler +//! This module provides URL query helpers. use std::collections::HashMap; -/// Extract the queries from a URL into a `HashMap`. +/// Extract decoded query pairs into a map. The last value wins for repeated +/// keys; use `Url::query_pairs` directly to retain repeated keys. #[must_use] pub fn queries_from_url(url: &url::Url) -> HashMap { let mut queries = HashMap::new(); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b9b02d7 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,27 @@ +# Tests + +Run the complete workspace with an explicitly selected runtime: + +```sh +cargo test --workspace --features windmark/tokio +cargo test --workspace --no-default-features --features windmark/tokio,windmark/auto-deduce-mime +cargo test --workspace --no-default-features --features windmark/async-std,windmark/auto-deduce-mime +``` + +The qualified runtime feature also selects the runtime for Rossweisse's +Windmark development dependency. + +## Layout + +`router.rs` is loaded as a private unit-test module to exercise internal +helpers without publishing them. Integration targets are explicitly listed in +`Cargo.toml`, so Cargo does not also compile that file as a separate crate. + +## Consumer Compatibility + +`consumer.rs` checks the independent crate in `consumer/`. + +The failing fixtures preserve known API limitations so changes to that +behaviour are deliberate. + +Format both the workspace and the independent consumer crate. diff --git a/tests/binary_response.rs b/tests/binary_response.rs index 47d3935..06cc3b5 100644 --- a/tests/binary_response.rs +++ b/tests/binary_response.rs @@ -11,9 +11,9 @@ const NON_UTF8: &[u8] = &[ fn binary_success_preserves_non_utf8_bytes() { let response = Response::binary_success(NON_UTF8, "image/png"); - assert_eq!(response.status, 21); - assert_eq!(response.mime.as_deref(), Some("image/png")); - assert_eq!(response.binary_content.as_deref(), Some(NON_UTF8)); + assert_eq!(response.status(), 21); + assert_eq!(response.mime(), Some("image/png")); + assert_eq!(response.binary_content(), Some(NON_UTF8)); } #[test] @@ -29,8 +29,8 @@ fn serialize_body_writes_binary_bytes_verbatim() { fn binary_success_auto_preserves_non_utf8_bytes() { let response = Response::binary_success_auto(NON_UTF8); - assert_eq!(response.status, 22); - assert_eq!(response.binary_content.as_deref(), Some(NON_UTF8)); + assert_eq!(response.status(), 22); + assert_eq!(response.binary_content(), Some(NON_UTF8)); assert_eq!( response.serialize_body("ignored-header", "ignored-footer"), NON_UTF8, @@ -42,5 +42,41 @@ fn serialize_body_text_success_wraps_with_header_and_footer() { let response = Response::success("body"); let body = response.serialize_body("HEADER\n", "FOOTER"); - assert_eq!(body, b"HEADER\nbody\nFOOTER"); + assert_eq!(body, b"HEADER\nbody\nFOOTER\n"); +} + +#[test] +fn public_binary_serialization_returns_the_existing_allocation() { + for status in [21, 22] { + let mut response = Response::new(status, "ignored"); + let mut bytes = Vec::with_capacity(1024); + + bytes.extend_from_slice(NON_UTF8); + + let pointer = bytes.as_ptr(); + let capacity = bytes.capacity(); + + *response.binary_content_mut().unwrap() = bytes; + + let body = response.serialize_body("ignored-header", "ignored-footer"); + + assert_eq!(body.as_ptr(), pointer); + assert_eq!(body.capacity(), capacity); + assert_eq!(body, NON_UTF8); + } +} + +#[test] +fn text_serialization_normalizes_line_endings_and_terminates_the_footer() { + let response = Response::success("one\r\ntwo\rthree"); + + assert_eq!( + response.serialize_body("header\r\n", "footer"), + b"header\none\ntwo\nthree\nfooter\n" + ); + assert_eq!( + Response::binary_success(b"one\r\ntwo\r", "text/plain") + .serialize_body("", ""), + b"one\r\ntwo\r" + ); } diff --git a/tests/consumer.rs b/tests/consumer.rs new file mode 100644 index 0000000..1fbedb7 --- /dev/null +++ b/tests/consumer.rs @@ -0,0 +1,114 @@ +use std::{path::PathBuf, process::Command}; + +#[test] +fn consumer_api_matches_expected_behavior() { + let manifest = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/consumer/Cargo.toml"); + let target = std::env::var_os("CARGO_TARGET_DIR") + .map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"), + PathBuf::from, + ) + .join("compatibility-consumer"); + #[cfg(feature = "tokio")] + let runtime = "tokio"; + #[cfg(feature = "async-std")] + let runtime = "async-std"; + let cases = [ + ("invalid_response_fields", "private", "auto-deduce-mime"), + ("valid", "", "auto-deduce-mime"), + ("automatic_mime", "", "auto-deduce-mime"), + ("binary_context", "", "auto-deduce-mime"), + ("async_context", "", "auto-deduce-mime"), + ("automatic_mime_disabled", "binary_success_auto", ""), + ( + "invalid_router_item", + "requires a struct or impl block", + "auto-deduce-mime", + ), + ( + "invalid_route_item", + "requires a function", + "auto-deduce-mime", + ), + ( + "invalid_router_fields", + "requires a struct with named fields or a unit struct", + "auto-deduce-mime", + ), + ("invalid_router_syntax", "expected", "auto-deduce-mime"), + ("invalid_route_syntax", "expected", "auto-deduce-mime"), + ("visibility", "", "auto-deduce-mime"), + ( + "visibility_private", + "struct `Capsule` is private", + "auto-deduce-mime", + ), + ( + "visibility_restricted", + "struct `Capsule` is private", + "auto-deduce-mime", + ), + ("index_name", "", "auto-deduce-mime"), + ("router_declarations", "", "auto-deduce-mime"), + ( + "invalid_router_duplicate", + "duplicate field initialiser", + "auto-deduce-mime", + ), + ( + "invalid_router_unknown", + "unknown field initialiser", + "auto-deduce-mime", + ), + ( + "invalid_router_reserved", + "reserved by Rossweisse", + "auto-deduce-mime", + ), + ( + "invalid_route_argument", + "only supported route argument", + "auto-deduce-mime", + ), + ]; + + for (name, diagnostic, extra_features) in cases { + let features = if extra_features.is_empty() { + runtime.to_owned() + } else { + format!("{runtime},{extra_features}") + }; + let output = Command::new(env!("CARGO")) + .arg(if diagnostic.is_empty() { + "run" + } else { + "check" + }) + .args(["--quiet", "--manifest-path"]) + .arg(&manifest) + .arg("--target-dir") + .arg(&target) + .args(["--features", &features, "--bin", name]) + .output() + .unwrap(); + let errors = String::from_utf8_lossy(&output.stderr); + + if name.starts_with("invalid_") { + assert!( + !errors.contains("custom attribute panicked"), + "{name}: {errors}" + ); + assert!(errors.contains(&format!("{name}.rs")), "{name}: {errors}"); + } + + if diagnostic.is_empty() { + assert!(output.status.success(), "{name}: {errors}"); + } else { + assert!( + !output.status.success() && errors.contains(diagnostic), + "{name}: {errors}" + ); + } + } +} diff --git a/tests/consumer/Cargo.toml b/tests/consumer/Cargo.toml new file mode 100644 index 0000000..3b00ab1 --- /dev/null +++ b/tests/consumer/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "windmark-compatibility-consumer" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[features] +tokio = ["windmark/tokio", "dep:tokio"] +async-std = ["windmark/async-std"] +auto-deduce-mime = ["windmark/auto-deduce-mime"] + +[dependencies] +windmark = { path = "../..", default-features = false } +rossweisse = { path = "../../rossweisse" } +async-trait = "0.1.68" +tokio = { version = "1.52.3", optional = true, features = ["macros", "rt"] } diff --git a/tests/consumer/src/bin/async_context.rs b/tests/consumer/src/bin/async_context.rs new file mode 100644 index 0000000..68fd1a8 --- /dev/null +++ b/tests/consumer/src/bin/async_context.rs @@ -0,0 +1,28 @@ +use std::future::Future; +use windmark::{context::RouteContext, response::Response}; + +fn main() { + let route = |context: RouteContext| { + async move { Response::success(std::future::ready(context.url.path()).await) } + }; + + for path in ["/one", "/two"] { + let context = RouteContext { + peer_address: None, + url: format!("gemini://localhost{path}").parse().unwrap(), + parameters: Default::default(), + certificate: None, + }; + let future = route(context); + let mut future = std::pin::pin!(future); + let mut task = std::task::Context::from_waker(std::task::Waker::noop()); + let std::task::Poll::Ready(response) = future.as_mut().poll(&mut task) + else { + panic!("the response should be ready"); + }; + + assert_eq!(response.content().unwrap(), path); + } + + windmark::router::Router::new().mount("/", route); +} diff --git a/tests/consumer/src/bin/automatic_mime.rs b/tests/consumer/src/bin/automatic_mime.rs new file mode 100644 index 0000000..33752b3 --- /dev/null +++ b/tests/consumer/src/bin/automatic_mime.rs @@ -0,0 +1,3 @@ +fn main() { + let _ = windmark::response::Response::binary_success_auto(b"hello"); +} diff --git a/tests/consumer/src/bin/automatic_mime_disabled.rs b/tests/consumer/src/bin/automatic_mime_disabled.rs new file mode 100644 index 0000000..33752b3 --- /dev/null +++ b/tests/consumer/src/bin/automatic_mime_disabled.rs @@ -0,0 +1,3 @@ +fn main() { + let _ = windmark::response::Response::binary_success_auto(b"hello"); +} diff --git a/tests/consumer/src/bin/binary_context.rs b/tests/consumer/src/bin/binary_context.rs new file mode 100644 index 0000000..240f55c --- /dev/null +++ b/tests/consumer/src/bin/binary_context.rs @@ -0,0 +1,12 @@ +use windmark::{context::RouteContext, response::Response, router::Router}; + +fn main() { + let mut router = Router::new(); + + router.mount("/explicit", |context: RouteContext| { + Response::binary_success(context.url.path().as_bytes(), "text/plain") + }); + router.mount("/inferred", |context: RouteContext| { + Response::binary_success_auto(context.url.path().as_bytes()) + }); +} diff --git a/tests/consumer/src/bin/index_name.rs b/tests/consumer/src/bin/index_name.rs new file mode 100644 index 0000000..ae19e01 --- /dev/null +++ b/tests/consumer/src/bin/index_name.rs @@ -0,0 +1,16 @@ +use rossweisse::route; + +#[rossweisse::router] +struct Capsule; + +#[rossweisse::router] +impl Capsule { + #[route(index)] + pub fn index( + _: windmark::context::RouteContext, + ) -> windmark::response::Response { + windmark::response::Response::success("index") + } +} + +fn main() { let _ = Capsule::index; } diff --git a/tests/consumer/src/bin/invalid_response_fields.rs b/tests/consumer/src/bin/invalid_response_fields.rs new file mode 100644 index 0000000..345710a --- /dev/null +++ b/tests/consumer/src/bin/invalid_response_fields.rs @@ -0,0 +1,5 @@ +fn main() { + let mut response = windmark::response::Response::success("hello"); + + response.status = 21; +} diff --git a/tests/consumer/src/bin/invalid_route_argument.rs b/tests/consumer/src/bin/invalid_route_argument.rs new file mode 100644 index 0000000..6b31b20 --- /dev/null +++ b/tests/consumer/src/bin/invalid_route_argument.rs @@ -0,0 +1,4 @@ +#[rossweisse::route(unknown)] +fn route() {} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_route_item.rs b/tests/consumer/src/bin/invalid_route_item.rs new file mode 100644 index 0000000..ea533b6 --- /dev/null +++ b/tests/consumer/src/bin/invalid_route_item.rs @@ -0,0 +1,4 @@ +#[rossweisse::route] +struct Route; + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_route_syntax.rs b/tests/consumer/src/bin/invalid_route_syntax.rs new file mode 100644 index 0000000..bec30a8 --- /dev/null +++ b/tests/consumer/src/bin/invalid_route_syntax.rs @@ -0,0 +1,10 @@ +macro_rules! invalid_declaration { + () => { + #[rossweisse::route] + fn route(value: ) {} + }; +} + +invalid_declaration!(); + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_duplicate.rs b/tests/consumer/src/bin/invalid_router_duplicate.rs new file mode 100644 index 0000000..69067cd --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_duplicate.rs @@ -0,0 +1,6 @@ +#[rossweisse::router(value = 1, value = 2)] +struct Capsule { + value: usize, +} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_fields.rs b/tests/consumer/src/bin/invalid_router_fields.rs new file mode 100644 index 0000000..72e57f4 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_fields.rs @@ -0,0 +1,4 @@ +#[rossweisse::router] +struct Capsule(usize); + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_item.rs b/tests/consumer/src/bin/invalid_router_item.rs new file mode 100644 index 0000000..0c1e153 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_item.rs @@ -0,0 +1,4 @@ +#[rossweisse::router] +enum Capsule {} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_reserved.rs b/tests/consumer/src/bin/invalid_router_reserved.rs new file mode 100644 index 0000000..b7507f5 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_reserved.rs @@ -0,0 +1,6 @@ +#[rossweisse::router] +struct Capsule { + router: usize, +} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_syntax.rs b/tests/consumer/src/bin/invalid_router_syntax.rs new file mode 100644 index 0000000..3eb7f34 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_syntax.rs @@ -0,0 +1,10 @@ +macro_rules! invalid_declaration { + () => { + #[rossweisse::router] + struct Capsule { value: , } + }; +} + +invalid_declaration!(); + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_unknown.rs b/tests/consumer/src/bin/invalid_router_unknown.rs new file mode 100644 index 0000000..ed982f8 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_unknown.rs @@ -0,0 +1,4 @@ +#[rossweisse::router(missing = 1)] +struct Capsule; + +fn main() {} diff --git a/tests/consumer/src/bin/router_declarations.rs b/tests/consumer/src/bin/router_declarations.rs new file mode 100644 index 0000000..cfc7928 --- /dev/null +++ b/tests/consumer/src/bin/router_declarations.rs @@ -0,0 +1,39 @@ +use std::marker::PhantomData; +use windmark::{context::RouteContext, response::Response}; + +#[rossweisse::router(value = T::default())] +#[derive(Clone)] +struct Capsule<'a, T: Default + Clone, const SIZE: usize = 2> { + value: T, + label: &'a str, + marker: PhantomData<[(); SIZE]>, + #[cfg(any())] + absent: Missing, + #[cfg_attr(all(), cfg(any()))] + also_absent: Missing, +} + +#[rossweisse::router] +impl<'a, T: Default + Clone, const SIZE: usize> Capsule<'a, T, SIZE> { + #[rossweisse::route(index)] + fn index(_: RouteContext) -> Response { Response::success("index") } + + #[cfg_attr(all(), cfg(any()))] + #[rossweisse::route] + fn absent(_: Missing) -> Missing { unreachable!() } +} + +#[rossweisse::router] +#[cfg_attr(all(), cfg(any()), derive(Clone))] +struct Absent; + +#[rossweisse::router] +#[cfg(any())] +impl Absent {} + +fn main() { + let capsule = Capsule::::new().clone(); + + assert_eq!(capsule.value, 0); + assert_eq!(capsule.label, ""); +} diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs new file mode 100644 index 0000000..cec8a01 --- /dev/null +++ b/tests/consumer/src/bin/valid.rs @@ -0,0 +1,151 @@ +use rossweisse::route; +use std::{ + future::IntoFuture, + sync::atomic::{AtomicUsize, Ordering}, +}; +use windmark::{ + context::{ErrorContext, HookContext, RouteContext}, + handler::{ + ErrorResponse, + Partial, + PostRouteHook, + PreRouteHook, + RouteResponse, + }, + module::{AsyncModule, Module}, + response::Response, + router::Router, + router_option::RouterOption, +}; + +#[rossweisse::router] +struct Empty; + +#[rossweisse::router] +impl Empty {} + +#[rossweisse::router(count = 7)] +struct Capsule { + count: usize, + label: String, +} + +#[rossweisse::router] +impl Capsule { + #[route(index)] + pub fn index(_: RouteContext) -> Response { Response::success("index") } +} + +struct Counter(AtomicUsize); + +impl Module for Counter { + fn on_pre_route(&self, _: &HookContext) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +struct AwaitableModule; + +#[async_trait::async_trait] +impl AsyncModule for AwaitableModule { + async fn on_pre_route(&self, _: &HookContext) {} +} + +struct Banner; + +impl Partial for Banner { + fn call(&self, _: &RouteContext) -> String { "banner".to_owned() } +} + +struct Observer; + +impl PreRouteHook for Observer { + fn call(&self, _: &HookContext) {} +} + +impl PostRouteHook for Observer { + fn call(&self, _: &HookContext, response: &mut Response) { + if let Some(content) = response.content_mut() { + content.push('!'); + } + } +} + +struct Replies; + +#[async_trait::async_trait] +impl RouteResponse for Replies { + async fn call(&self, _: RouteContext) -> Response { + Response::success("route") + } +} + +#[async_trait::async_trait] +impl ErrorResponse for Replies { + async fn call(&self, _: ErrorContext) -> Response { + Response::not_found("missing") + } +} + +struct AwaitableResponse; + +impl IntoFuture for AwaitableResponse { + type IntoFuture = std::future::Ready; + type Output = Response; + + fn into_future(self) -> Self::IntoFuture { + std::future::ready(Response::success("awaitable")) + } +} + +struct Bytes; + +impl AsRef<[u8]> for Bytes { + fn as_ref(&self) -> &[u8] { b"bytes" } +} + +fn context() -> RouteContext { + RouteContext { + peer_address: None, + url: "gemini://localhost/path".parse().unwrap(), + parameters: Default::default(), + certificate: None, + } +} + +async fn attach_module(router: &mut Router) { + router.attach_async(AwaitableModule).await; +} + +fn main() { + let mut router = Router::new(); + let capsule = Capsule::new(); + let _empty = Empty::new(); + let _attachment = attach_module; + let _route: &dyn RouteResponse = &Replies; + let _error: &dyn ErrorResponse = &Replies; + + router.attach(Counter(AtomicUsize::new(0))); + router.add_header(Banner); + router.add_footer(Banner); + router.set_pre_route_callback(Observer); + router.set_post_route_callback(Observer); + router.mount("/awaitable", |_| AwaitableResponse); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert_eq!(capsule.count, 7); + assert!(capsule.label.is_empty()); + assert_eq!(Capsule::index(context()).content().unwrap(), "index"); + + let mut response = Response::input::("prompt".to_owned()); + + assert_eq!(response.content(), Some("prompt")); + + response = Response::new(79, "metadata"); + *response.mime_mut() = Some("text/plain; charset=iso-8859-1".to_owned()); + *response.character_set_mut() = Some("iso-8859-1".to_owned()); + *response.languages_mut() = Some(vec!["en".to_owned()]); + + assert!(response.serialize_body("header", "footer").is_empty()); + assert_eq!(Response::binary_success(Bytes, "text/plain").status(), 21); + assert_eq!(Response::binary_success_auto(b"hello").status(), 22); +} diff --git a/tests/consumer/src/bin/visibility.rs b/tests/consumer/src/bin/visibility.rs new file mode 100644 index 0000000..3d6a31a --- /dev/null +++ b/tests/consumer/src/bin/visibility.rs @@ -0,0 +1,29 @@ +mod capsule { + #[rossweisse::router] + pub struct Capsule; + + #[rossweisse::router] + pub(crate) struct Stateful { + visits: usize, + } + + mod nested { + #[rossweisse::router] + pub(super) struct ParentVisible; + + #[rossweisse::router] + pub(in crate::capsule) struct Scoped; + } + + pub fn check_nested_visibility() { + let _: Option = None; + let _: Option = None; + } +} + +fn main() { + let _: Option = None; + let _: Option = None; + + capsule::check_nested_visibility(); +} diff --git a/tests/consumer/src/bin/visibility_private.rs b/tests/consumer/src/bin/visibility_private.rs new file mode 100644 index 0000000..c9adc37 --- /dev/null +++ b/tests/consumer/src/bin/visibility_private.rs @@ -0,0 +1,6 @@ +mod capsule { + #[rossweisse::router] + struct Capsule; +} + +fn main() { let _: Option = None; } diff --git a/tests/consumer/src/bin/visibility_restricted.rs b/tests/consumer/src/bin/visibility_restricted.rs new file mode 100644 index 0000000..f5fb1dd --- /dev/null +++ b/tests/consumer/src/bin/visibility_restricted.rs @@ -0,0 +1,8 @@ +mod capsule { + pub mod nested { + #[rossweisse::router] + pub(in crate::capsule) struct Capsule; + } +} + +fn main() { let _: Option = None; } diff --git a/tests/module.rs b/tests/module.rs new file mode 100644 index 0000000..4101137 --- /dev/null +++ b/tests/module.rs @@ -0,0 +1,46 @@ +use std::{ + future::Future, + pin::pin, + sync::{Arc, Mutex}, + task::Poll, +}; +use windmark::{module::AsyncModule, router::Router}; + +struct Initializer(Arc>>); + +#[async_trait::async_trait] +impl AsyncModule for Initializer { + async fn on_attach(&mut self, router: &mut Router) { + self.0.lock().unwrap().push("started"); + #[cfg(feature = "tokio")] + tokio::task::yield_now().await; + #[cfg(feature = "async-std")] + async_std::task::yield_now().await; + router.set_port(0); + self.0.lock().unwrap().push("finished"); + } +} + +#[cfg_attr(feature = "tokio", tokio::test(flavor = "current_thread"))] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn attachment_awaits_initialization_and_returns_the_router() { + let events = Arc::new(Mutex::new(Vec::new())); + let mut router = Router::new(); + let address = std::ptr::from_ref(&router); + let mut attachment = pin!(router.attach_async(Initializer(events.clone()))); + + assert!(events.lock().unwrap().is_empty()); + std::future::poll_fn(|context| { + assert!(attachment.as_mut().poll(context).is_pending()); + + Poll::Ready(()) + }) + .await; + assert_eq!(*events.lock().unwrap(), ["started"]); + + let attached = attachment.await; + + assert_eq!(*events.lock().unwrap(), ["started", "finished"]); + assert_eq!(std::ptr::from_ref(attached), address); + attached.set_port(1965); +} diff --git a/tests/response.rs b/tests/response.rs new file mode 100644 index 0000000..fd0303d --- /dev/null +++ b/tests/response.rs @@ -0,0 +1,60 @@ +use windmark::response::Response; + +#[test] +fn payload_access_is_exclusive_and_statuses_remain_numeric() { + for status in [10, 20, 40, 51, 79] { + let mut response = Response::new(status, "message"); + + assert_eq!(response.status(), status); + assert!(response.binary_content().is_none()); + assert!(response.binary_content_mut().is_none()); + response.content_mut().unwrap().push('!'); + assert_eq!(response.content(), Some("message!")); + } + + for status in [21, 22] { + let mut response = Response::new(status, "ignored"); + + assert_eq!(response.status(), status); + assert!(response.content().is_none()); + assert!(response.content_mut().is_none()); + response.binary_content_mut().unwrap().extend([0xff, 0]); + assert_eq!(response.binary_content(), Some([0xff, 0].as_slice())); + } +} + +#[test] +fn metadata_overrides_can_be_edited_and_cleared() { + let mut response = Response::success("body"); + + response.with_mime("text/plain"); + response.with_character_set("ascii"); + response.with_languages(["en", "fr"]); + assert_eq!(response.mime(), Some("text/plain")); + assert_eq!(response.character_set(), Some("ascii")); + assert_eq!(response.languages().unwrap(), ["en", "fr"]); + response.languages_mut().as_mut().unwrap().clear(); + assert_eq!(response.languages(), Some([].as_slice())); + + *response.mime_mut() = None; + *response.character_set_mut() = None; + *response.languages_mut() = None; + + assert!(response.mime().is_none()); + assert!(response.character_set().is_none()); + assert!(response.languages().is_none()); +} + +#[test] +fn replacing_a_response_discards_the_old_payload_and_overrides() { + let mut response = Response::binary_success([0xff], "image/png"); + + assert!(response.binary_content().is_some()); + + response = Response::not_found("Missing"); + + assert_eq!(response.status(), 51); + assert_eq!(response.content(), Some("Missing")); + assert!(response.binary_content().is_none()); + assert!(response.mime().is_none()); +} diff --git a/tests/router.rs b/tests/router.rs new file mode 100644 index 0000000..a3cb3a3 --- /dev/null +++ b/tests/router.rs @@ -0,0 +1,810 @@ +use super::{resolve_lookup_path, status_line}; +use crate::{response::Response, router_option::RouterOption}; +use matchit::Router as MatchRouter; +use std::collections::HashSet; + +/// Resolve `request_path` against a router holding `routes`, under `options`. +fn resolve( + options: &[RouterOption], + request_path: &str, + routes: &[&str], +) -> String { + let mut matcher = MatchRouter::new(); + + for route in routes { + matcher.insert(*route, ()).unwrap(); + } + + let option_set = options.iter().copied().collect::>(); + + resolve_lookup_path(&option_set, request_path, |candidate| { + let lookup = if options.contains(&RouterOption::AllowCaseInsensitiveLookup) + { + candidate.to_ascii_lowercase() + } else { + candidate.to_owned() + }; + + matcher.at(&lookup).is_ok() + }) +} + +#[test] +fn exact_match_is_returned_unchanged() { + assert_eq!(resolve(&[], "/foo", &["/foo"]), "/foo"); +} + +#[test] +fn unmatched_path_without_options_is_returned_unchanged() { + assert_eq!(resolve(&[], "/missing", &["/foo"]), "/missing"); +} + +#[test] +fn dynamic_route_is_matched_without_modification() { + assert_eq!(resolve(&[], "/posts/42", &["/posts/:id"]), "/posts/42"); +} + +#[test] +fn case_insensitive_lookup_preserves_the_path() { + assert_eq!( + resolve( + &[RouterOption::AllowCaseInsensitiveLookup], + "/FoO", + &["/foo"] + ), + "/FoO", + ); +} + +#[test] +fn extra_trailing_slash_is_removed_when_unslashed_route_exists() { + assert_eq!( + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/foo"] + ), + "/foo", + ); +} + +#[test] +fn root_path_is_left_untouched_by_trailing_slash_removal() { + assert_eq!( + resolve(&[RouterOption::RemoveExtraTrailingSlash], "/", &["/"]), + "/", + ); +} + +#[test] +fn missing_trailing_slash_is_added_when_slashed_route_exists() { + assert_eq!( + resolve(&[RouterOption::AddMissingTrailingSlash], "/foo", &["/foo/"]), + "/foo/", + ); +} + +#[test] +fn trailing_slash_fix_is_skipped_when_the_target_route_is_absent() { + assert_eq!( + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/bar"] + ), + "/foo/", + ); +} + +#[test] +fn exact_match_takes_precedence_over_trailing_slash_fix() { + assert_eq!( + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/foo", "/foo/"], + ), + "/foo/", + ); +} + +#[test] +fn case_insensitive_lookup_combines_with_trailing_slash_removal() { + assert_eq!( + resolve( + &[ + RouterOption::AllowCaseInsensitiveLookup, + RouterOption::RemoveExtraTrailingSlash, + ], + "/Foo/", + &["/foo"], + ), + "/Foo", + ); +} + +/// Format `response`'s status line with the router's default charset and +/// language (matching `Router::default`). +fn line(response: &Response) -> String { + status_line(response, "utf-8", "en").unwrap() +} + +#[test] +fn success_status_line_falls_back_to_router_defaults() { + assert_eq!( + line(&Response::success("hi")), + "20 text/gemini; charset=utf-8; lang=en", + ); +} + +#[test] +fn success_status_line_uses_the_response_mime() { + let mut response = Response::success("hi"); + + response.with_mime("text/plain"); + assert_eq!(line(&response), "20 text/plain; charset=utf-8; lang=en"); +} + +#[test] +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" + ); +} + +#[test] +fn success_status_line_joins_the_response_languages() { + let mut response = Response::success("hi"); + + response.with_languages(["en", "fr"]); + assert_eq!( + line(&response), + "20 text/gemini; charset=utf-8; lang=\"en,fr\"" + ); +} + +#[test] +fn binary_success_is_sent_as_a_plain_success_with_its_mime() { + assert_eq!( + line(&Response::binary_success([0xFFu8], "image/png")), + "20 image/png" + ); +} + +#[test] +fn status_21_without_a_mime_uses_binary_media_type() { + assert_eq!(line(&Response::new(21, "")), "20 application/octet-stream"); +} + +#[test] +fn redirect_status_line_uses_its_content_as_the_meta() { + assert_eq!( + line(&Response::temporary_redirect("/elsewhere")), + "30 /elsewhere" + ); +} + +#[test] +fn failure_status_line_uses_only_the_first_line_of_content() { + assert_eq!(line(&Response::new(40, "down\nfor maintenance")), "40 down"); +} + +#[test] +fn input_status_line_uses_its_prompt_as_the_meta() { + assert_eq!(line(&Response::input("Your name?")), "10 Your name?"); +} + +#[test] +fn footer_rendering_preserves_empty_values_and_callback_order() { + let context = crate::context::RouteContext { + peer_address: None, + url: "gemini://localhost/footer".parse().unwrap(), + parameters: crate::context::Parameters::default(), + certificate: None, + }; + + for values in [vec![], vec![""], vec!["", ""], vec!["a", "", "b\n"]] { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let partials: Vec> = values + .iter() + .enumerate() + .map(|(index, value)| { + let calls = calls.clone(); + let value = (*value).to_owned(); + + std::sync::Arc::new(move |context: &crate::context::RouteContext| { + assert_eq!(context.url.path(), "/footer"); + calls.lock().unwrap().push(index); + value.clone() + }) as std::sync::Arc + }) + .collect(); + + assert_eq!(super::render_footer(&partials, &context), values.join("\n")); + assert_eq!( + *calls.lock().unwrap(), + (0..values.len()).collect::>() + ); + } +} + +#[test] +fn path_resolution_preserves_unicode_and_probe_order() { + let options = [ + RouterOption::AllowCaseInsensitiveLookup, + RouterOption::AddMissingTrailingSlash, + ] + .into_iter() + .collect(); + let probes = std::cell::RefCell::new(Vec::new()); + let path = resolve_lookup_path(&options, "/Users/ÄLICE", |candidate| { + probes.borrow_mut().push(candidate.to_owned()); + + candidate == "/Users/ÄLICE/" + }); + + assert_eq!(path, "/Users/ÄLICE/"); + assert_eq!(*probes.borrow(), ["/Users/ÄLICE", "/Users/ÄLICE/"]); +} + +#[test] +fn response_assembly_preserves_publicly_constructible_states() { + for (status, expected_line, expected_body) in [ + ( + 20, + "20 text/gemini; charset=utf-8; lang=en", + b"HEADERbody\nFOOTER\n".as_slice(), + ), + (21, "20 application/octet-stream", b"\x00\xff".as_slice()), + (22, "20 application/octet-stream", b"\x00\xff".as_slice()), + (40, "40 body", b"".as_slice()), + ] { + let mut response = Response::new(status, "body"); + + if let Some(bytes) = response.binary_content_mut() { + *bytes = vec![0, 0xff]; + } + + let status_line = line(&response); + let expected = [expected_line.as_bytes(), b"\r\n", expected_body].concat(); + + assert_eq!(status_line, expected_line); + assert_eq!( + response.clone().serialize_body("HEADER", "FOOTER"), + expected_body + ); + assert_eq!( + super::serialize_response(response, &status_line, "HEADER", "FOOTER"), + expected + ); + } + + for status in [21, 22] { + let response = Response::new(status, "ignored"); + + assert!(response + .clone() + .serialize_body("HEADER", "FOOTER") + .is_empty()); + assert_eq!( + super::serialize_response( + response, + "20 application/octet-stream", + "HEADER", + "FOOTER" + ), + b"20 application/octet-stream\r\n" + ); + } +} + +#[test] +fn case_insensitive_routes_preserve_parameter_names_and_values() { + let mut router = super::Router::new(); + + router.mount("/Users/:Name/Files/*FilePath", |_| { + Response::success("file") + }); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + + let matched = router + .routes + .at("/uSeRs/ÄLICE/fIlEs/Photo%2FOne.PNG") + .unwrap(); + + assert_eq!(matched.parameters.get("Name"), Some("ÄLICE")); + assert_eq!(matched.parameters.get("FilePath"), Some("Photo%2FOne.PNG")); + assert_eq!(matched.parameters.get("name"), None); +} + +#[test] +fn case_insensitive_routes_preserve_static_priority_and_inline_parameters() { + let mut router = super::Router::new(); + + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + router.mount("/Users/:Name", |_| Response::success("user")); + router.mount("/Users/Me", |_| Response::success("profile")); + router.mount("/Files/Prefix:Name", |_| Response::success("file")); + + let profile = router.routes.at("/users/ME").unwrap(); + let file = router.routes.at("/FILES/prefixREPORT.PDF").unwrap(); + + assert!(profile.parameters.is_empty()); + assert_eq!(file.parameters.get("Name"), Some("REPORT.PDF")); +} + +#[test] +fn case_insensitive_routes_restore_repeated_values_from_their_own_positions() { + let mut router = super::Router::new(); + + router.mount("/Same/:First/:Second", |_| Response::success("pair")); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + + let matched = router.routes.at("/same/SAME/SaMe").unwrap(); + + assert_eq!(matched.parameters.get("First"), Some("SAME")); + assert_eq!(matched.parameters.get("Second"), Some("SaMe")); +} + +#[test] +fn case_insensitive_matching_can_be_disabled_reenabled_and_cloned() { + let mut router = super::Router::new(); + + router.mount("/Mixed/:Name", |_| Response::success("mixed")); + assert!(!router.routes.contains("/mixed/Alice")); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(router.routes.contains("/mixed/Alice")); + + let cloned = router.clone(); + + router.toggle_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(!router.routes.contains("/mixed/Alice")); + assert!(cloned.routes.contains("/mixed/Alice")); + router.toggle_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(router.routes.contains("/mixed/Alice")); + router.remove_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(!router.routes.contains("/mixed/Alice")); + assert!(router.routes.contains("/Mixed/Alice")); +} + +#[test] +fn enabling_case_insensitive_matching_rejects_existing_conflicts() { + let mut router = super::Router::new(); + + router.mount("/Foo", |_| Response::success("upper")); + router.mount("/foo", |_| Response::success("lower")); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + })); + + assert!(result.is_err()); + assert!(!router + .options + .contains(&RouterOption::AllowCaseInsensitiveLookup)); + assert!(router.routes.contains("/Foo")); + assert!(router.routes.contains("/foo")); + assert!(!router.routes.contains("/FOO")); +} + +#[test] +fn mounting_case_conflicts_preserves_existing_routes() { + let mut router = super::Router::new(); + + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + router.mount("/Users/:Name", |_| Response::success("user")); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + router.mount("/users/:Other", |_| Response::success("conflict")); + })); + + assert!(result.is_err()); + + let matched = router.routes.at("/USERS/Alice").unwrap(); + + assert_eq!(matched.parameters.get("Name"), Some("Alice")); + assert_eq!(matched.parameters.get("Other"), None); + router.mount("/Other", |_| Response::success("other")); + assert!(router.routes.contains("/OTHER")); +} + +#[test] +fn trailing_slash_fixes_preserve_case_insensitive_parameters() { + let mut router = super::Router::new(); + + router.mount("/Users/:Name/", |_| Response::success("user")); + router.add_options(&[ + RouterOption::AllowCaseInsensitiveLookup, + RouterOption::AddMissingTrailingSlash, + ]); + + let path = + resolve_lookup_path(&router.options, "/uSeRs/Alice", |candidate| { + router.routes.contains(candidate) + }); + let matched = router.routes.at(&path).unwrap(); + + assert_eq!(path, "/uSeRs/Alice/"); + assert_eq!(matched.parameters.get("Name"), Some("Alice")); +} + +#[test] +fn shared_synchronous_modules_allow_overlapping_invocations() { + struct SignalingModule(std::sync::mpsc::Sender<()>); + + impl crate::module::Module for SignalingModule { + fn on_pre_route(&self, _: &crate::context::HookContext) { + self.0.send(()).unwrap(); + } + } + + struct BlockingModule { + entered: std::sync::mpsc::Sender<()>, + release: std::sync::Mutex>, + blocked: std::sync::atomic::AtomicBool, + } + + impl crate::module::Module for BlockingModule { + fn on_pre_route(&self, _: &crate::context::HookContext) { + if !self + .blocked + .swap(true, std::sync::atomic::Ordering::Relaxed) + { + self.entered.send(()).unwrap(); + self + .release + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + } + } + + let (signal, progress) = std::sync::mpsc::channel(); + let (entered, entry) = std::sync::mpsc::channel(); + let (release, resume) = std::sync::mpsc::channel(); + let mut router = super::Router::new(); + + router.attach(SignalingModule(signal)); + router.attach(BlockingModule { + entered, + release: std::sync::Mutex::new(resume), + blocked: std::sync::atomic::AtomicBool::new(false), + }); + + let modules = router.modules.clone(); + let scheduling = std::sync::Arc::new(super::ModuleScheduling::default()); + let first_scheduling = scheduling.clone(); + let first_modules = modules.clone(); + + scheduling + .concurrent + .store(true, std::sync::atomic::Ordering::Relaxed); + + let first = std::thread::spawn(move || { + super::call_modules(&first_modules, &first_scheduling, |module| { + module.on_pre_route(&crate::context::HookContext { + peer_address: None, + url: url::Url::parse("gemini://localhost/").unwrap(), + parameters: None, + certificate: None, + }); + }); + }); + + progress + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + entry + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + + let second = std::thread::spawn(move || { + super::call_modules(&modules, &scheduling, |module| { + module.on_pre_route(&crate::context::HookContext { + peer_address: None, + url: url::Url::parse("gemini://localhost/").unwrap(), + parameters: None, + certificate: None, + }); + }); + }); + let advanced = progress.recv_timeout(std::time::Duration::from_secs(5)); + + second.join().unwrap(); + release.send(()).unwrap(); + first.join().unwrap(); + advanced.expect("the first module was blocked by the second module"); +} + +#[test] +fn module_scheduling_options_are_shared_across_router_clones() { + let mut router = super::Router::new(); + let mut cloned = router.clone(); + let enabled = || { + router + .module_scheduling + .concurrent + .load(std::sync::atomic::Ordering::Relaxed) + }; + + assert!(!enabled()); + cloned + .add_options(&[crate::router_option::RouterOption::AllowConcurrentModules]); + assert!(enabled()); + cloned.toggle_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + assert!(!enabled()); + cloned.toggle_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + assert!(enabled()); + router.remove_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + assert!(!cloned + .module_scheduling + .concurrent + .load(std::sync::atomic::Ordering::Relaxed)); +} + +#[test] +fn synchronous_hook_phases_take_exclusive_or_shared_permits() { + let mut router = super::Router::new(); + + router.attach(SchedulingProbe); + super::call_modules(&router.modules, &router.module_scheduling, |_| { + assert!(router.module_scheduling.synchronous.try_read().is_err()); + }); + router + .add_options(&[crate::router_option::RouterOption::AllowConcurrentModules]); + super::call_modules(&router.modules, &router.module_scheduling, |_| { + assert!(router.module_scheduling.synchronous.try_read().is_ok()); + assert!(router.module_scheduling.synchronous.try_write().is_err()); + }); +} + +struct SchedulingProbe; + +impl crate::module::Module for SchedulingProbe {} + +#[async_trait::async_trait] +impl crate::module::AsyncModule for SchedulingProbe {} + +#[cfg_attr(feature = "tokio", tokio::test(flavor = "current_thread"))] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn exclusive_async_phases_wait_for_existing_concurrent_phases() { + use std::{future::Future, pin::pin, task::Poll}; + + let mut router = super::Router::new(); + let context = crate::context::HookContext { + peer_address: None, + url: url::Url::parse("gemini://localhost/").unwrap(), + parameters: None, + certificate: None, + }; + + router.attach_async(SchedulingProbe).await; + + for phase in [super::HookPhase::PreRoute, super::HookPhase::PostRoute] { + let shared = router.module_scheduling.asynchronous.read().await; + let mut dispatch = pin!(super::call_async_modules( + &router.async_modules, + &router.module_scheduling, + &context, + phase, + )); + + std::future::poll_fn(|context| { + assert!(dispatch.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + drop(shared); + + dispatch.await; + } + + router + .add_options(&[crate::router_option::RouterOption::AllowConcurrentModules]); + + let scheduling = router.module_scheduling.clone(); + let modules = router.async_modules.clone(); + let shared = scheduling.asynchronous.read().await; + let mut concurrent = pin!(super::call_async_modules( + &modules, + &scheduling, + &context, + super::HookPhase::PreRoute, + )); + + std::future::poll_fn(|context| { + assert!(concurrent.as_mut().poll(context).is_ready()); + Poll::Ready(()) + }) + .await; + router.remove_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + + let mut exclusive = pin!(super::call_async_modules( + &modules, + &scheduling, + &context, + super::HookPhase::PostRoute, + )); + + std::future::poll_fn(|context| { + assert!(exclusive.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + drop(shared); + + exclusive.await; +} + +#[test] +fn request_uris_reject_forbidden_syntax_before_normalization() { + for request in [ + "gemini://user@localhost/", + "gemini://@localhost/", + "gemini://localhost/#", + "gemini://localhost/#fragment", + "gemini://localhost/\t", + "gemini://localhost/?hello world", + "gemini://localhost/?%ZZ", + "gemini://localhost/?%0", + "gemini://localhost/?%", + "gemini://localhost/[path]", + "gemini://localhost/café", + "gemini:///", + "/relative", + ] { + assert!(super::request::parse_uri(request).is_err(), "{request}"); + } + + for request in [ + "gemini://localhost/", + "gemini://localhost", + "gemini://[::1]/", + "gemini://localhost/?hello%20world", + "gemini://localhost/caf%C3%A9", + "gemini://localhost/%23%40%5B", + "gopher://localhost/1/example", + "https://localhost/", + "mailto:user@example.com", + ] { + assert!(super::request::parse_uri(request).is_ok(), "{request}"); + } +} + +#[test] +fn response_headers_reject_invalid_statuses_and_metadata() { + for response in [ + Response::new(-1, "message"), + Response::new(23, "message"), + Response::new(99, "message"), + Response::input(""), + Response::temporary_redirect(""), + Response::temporary_redirect("/hello world"), + Response::temporary_redirect("/%XX"), + Response::temporary_redirect("/[path]"), + Response::temporary_redirect("/#one#two"), + Response::bad_request("one\rtwo"), + Response::bad_request("one\u{0085}two"), + Response::binary_success([], "text/plain\r\ninjected"), + Response::binary_success([], "invalid media type"), + ] { + assert!(status_line(&response, "utf-8", "en").is_err()); + } + + assert_eq!(line(&Response::temporary_failure("")), "40"); + assert_eq!( + line(&Response::temporary_redirect("../page#part")), + "30 ../page#part" + ); + assert_eq!( + line(&Response::temporary_redirect("https://example.com/")), + "30 https://example.com/" + ); +} + +#[test] +fn response_languages_support_private_tags_and_omission() { + let mut response = Response::success("hello"); + + response.with_languages(["x-private", "zh-Hans-CN"]); + assert_eq!( + line(&response), + "20 text/gemini; charset=utf-8; lang=\"x-private,zh-Hans-CN\"" + ); + response.with_languages(Vec::::new()); + assert_eq!(line(&response), "20 text/gemini; charset=utf-8"); + response.with_languages(["en;injected=value"]); + assert!(status_line(&response, "utf-8", "en").is_err()); +} + +#[test] +fn credentials_load_complete_chains_and_use_the_last_setter() { + use openssl::{ + asn1::Asn1Time, + ec::{EcGroup, EcKey}, + hash::MessageDigest, + nid::Nid, + pkey::PKey, + x509::{X509NameBuilder, X509}, + }; + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap(); + let mut name = X509NameBuilder::new().unwrap(); + + name.append_entry_by_text("CN", "localhost").unwrap(); + + let name = name.build(); + let mut certificate = X509::builder().unwrap(); + + certificate.set_version(2).unwrap(); + certificate.set_subject_name(&name).unwrap(); + certificate.set_issuer_name(&name).unwrap(); + certificate.set_pubkey(&key).unwrap(); + certificate + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + certificate + .set_not_after(&Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + certificate.sign(&key, MessageDigest::sha256()).unwrap(); + + let certificate = certificate.build(); + let certificate_pem = + String::from_utf8(certificate.to_pem().unwrap()).unwrap(); + let key_pem = + String::from_utf8(key.private_key_to_pem_pkcs8().unwrap()).unwrap(); + let chain = certificate_pem.repeat(2); + let directory = std::env::temp_dir().join(format!( + "windmark-credentials-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("chain.pem"), &chain).unwrap(); + std::fs::write(directory.join("key.pem"), &key_pem).unwrap(); + + let mut router = super::Router::new(); + + router.set_certificate("invalid"); + router.set_private_key("invalid"); + router.set_certificate_file(directory.join("chain.pem").to_str().unwrap()); + router.set_private_key_file(directory.join("key.pem").to_str().unwrap()); + + let acceptor = router.create_acceptor().unwrap(); + + assert_eq!(acceptor.context().extra_chain_certs().len(), 1); + router.set_certificate_file("missing-certificate.pem"); + router.set_private_key_file("missing-key.pem"); + router.set_certificate(chain); + router.set_private_key(key_pem); + + let acceptor = router.create_acceptor().unwrap(); + + assert_eq!(acceptor.context().extra_chain_certs().len(), 1); + assert_eq!( + acceptor.context().certificate().unwrap().to_der().unwrap(), + certificate.to_der().unwrap(), + ); + router.set_certificate(""); + assert!(router.create_acceptor().is_err()); + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs new file mode 100644 index 0000000..febb039 --- /dev/null +++ b/tests/router_runtime.rs @@ -0,0 +1,1087 @@ +use openssl::{ + asn1::Asn1Time, + ec::{EcGroup, EcKey}, + hash::MessageDigest, + nid::Nid, + pkey::PKey, + ssl::{SslAcceptor, SslConnector, SslMethod, SslVerifyMode}, + x509::{X509NameBuilder, X509}, +}; +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use windmark::{ + context::{HookContext, RouteContext}, + module::{AsyncModule, Module}, + response::Response, + router::{Router, Server}, + router_option::RouterOption, +}; + +type Events = Arc>>; + +struct Observer(Events); + +impl Module for Observer { + fn on_attach(&mut self, _: &mut Router) { + self.0.lock().unwrap().push("attach"); + } + + fn on_pre_route(&self, context: &HookContext) { + if context.url.path() == "/Users/Alice" { + assert_eq!( + context.parameters.as_ref().unwrap().get("Name"), + Some("Alice") + ); + } + + self.0.lock().unwrap().push("module-pre"); + } + + fn on_post_route(&self, context: &HookContext) { + if context.url.path() == "/Users/Alice" { + assert_eq!( + context.parameters.as_ref().unwrap().get("Name"), + Some("Alice") + ); + } + + self.0.lock().unwrap().push("module-post"); + } +} + +struct AsyncObserver { + events: Events, + initialized: bool, +} + +#[async_trait::async_trait] +impl AsyncModule for AsyncObserver { + async fn on_attach(&mut self, _: &mut Router) { + self.initialized = true; + + self.events.lock().unwrap().push("async-attach"); + } + + async fn on_pre_route(&self, _: &HookContext) { + assert!(self.initialized); + self.events.lock().unwrap().push("async-pre"); + } + + async fn on_post_route(&self, _: &HookContext) { + self.events.lock().unwrap().push("async-post"); + } +} + +fn acceptor() -> SslAcceptor { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap(); + let mut name = X509NameBuilder::new().unwrap(); + + name.append_entry_by_text("CN", "localhost").unwrap(); + + let name = name.build(); + let mut certificate = X509::builder().unwrap(); + + certificate.set_version(2).unwrap(); + certificate.set_subject_name(&name).unwrap(); + certificate.set_issuer_name(&name).unwrap(); + certificate.set_pubkey(&key).unwrap(); + certificate + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + certificate + .set_not_after(&Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + certificate.sign(&key, MessageDigest::sha256()).unwrap(); + + let mut builder = + SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); + + builder.set_certificate(&certificate.build()).unwrap(); + builder.set_private_key(&key).unwrap(); + builder.build() +} + +fn request(address: SocketAddr, path: &str, expected: &[u8]) { + request_chunks( + address, + &[format!("gemini://localhost{path}\r\n").as_bytes()], + expected, + ); +} + +fn connect_tcp(address: SocketAddr) -> TcpStream { + let started = Instant::now(); + let stream = loop { + match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + Ok(stream) => break stream, + + Err(error) => { + assert!(started.elapsed() < Duration::from_secs(5), "{error}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + }; + + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + stream +} + +fn connect(address: SocketAddr) -> openssl::ssl::SslStream { + let stream = connect_tcp(address); + let mut connector = SslConnector::builder(SslMethod::tls()).unwrap(); + + connector.set_verify(SslVerifyMode::NONE); + connector.build().connect("localhost", stream).unwrap() +} + +fn request_chunks(address: SocketAddr, chunks: &[&[u8]], expected: &[u8]) { + exchange(connect(address), chunks, expected); +} + +fn exchange( + mut stream: openssl::ssl::SslStream, + chunks: &[&[u8]], + expected: &[u8], +) { + let mut output = vec![0; expected.len()]; + + for chunk in chunks { + stream.write_all(chunk).unwrap(); + } + + stream.read_exact(&mut output).unwrap(); + assert_eq!(output, expected); + assert_eq!(stream.read(&mut [0]).unwrap(), 0); + assert!(stream + .get_shutdown() + .contains(openssl::ssl::ShutdownState::RECEIVED)); +} + +async fn serve_requests( + router: Router, + client: impl FnOnce(SocketAddr) + Send + 'static, +) { + serve_requests_until(router, client, std::future::pending()).await; +} + +async fn serve_requests_until( + mut router: Router, + client: impl FnOnce(SocketAddr) + Send + 'static, + shutdown: impl std::future::Future + Send + 'static, +) { + router.set_listener_address("127.0.0.1"); + router.set_port(0); + router.set_ssl_acceptor(acceptor()); + + let bound = router.bind().await.unwrap(); + + serve_bound_requests(bound, client, shutdown).await; +} + +async fn serve_bound_requests( + bound: Server, + client: impl FnOnce(SocketAddr) + Send + 'static, + shutdown: impl std::future::Future + Send + 'static, +) { + let address = bound.local_addr().unwrap(); + #[cfg(feature = "tokio")] + let server = tokio::spawn(async move { bound.run_until(shutdown).await }); + #[cfg(feature = "async-std")] + let server = + async_std::task::spawn(async move { bound.run_until(shutdown).await }); + #[cfg(feature = "tokio")] + let run_client = tokio::task::spawn_blocking; + #[cfg(feature = "async-std")] + let run_client = async_std::task::spawn_blocking; + let client = run_client(move || client(address)); + #[cfg(feature = "tokio")] + let result = client.await; + + #[cfg(feature = "async-std")] + client.await; + + #[cfg(feature = "tokio")] + server.abort(); + #[cfg(feature = "async-std")] + server.cancel().await; + #[cfg(feature = "tokio")] + result.unwrap(); +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn callbacks_partials_and_case_folding_keep_their_existing_order() { + let events: Events = Arc::default(); + let mut router = Router::new(); + + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + router.attach(Observer(events.clone())); + router + .attach_async(AsyncObserver { + events: events.clone(), + initialized: false, + }) + .await; + + let recorded = events.clone(); + + router.set_pre_route_callback(move |_: &HookContext| { + recorded.lock().unwrap().push("callback-pre"); + }); + + let recorded = events.clone(); + + router.add_header(move |_: &RouteContext| { + recorded.lock().unwrap().push("header"); + "HEADER".to_owned() + }); + + let recorded = events.clone(); + + router.add_footer(move |_: &RouteContext| { + recorded.lock().unwrap().push("footer"); + "FOOTER".to_owned() + }); + + let recorded = events.clone(); + + router.mount("/Users/:Name", move |context: RouteContext| { + recorded.lock().unwrap().push("route"); + Response::success(context.parameters.get("Name").unwrap()) + }); + + let recorded = events.clone(); + + router.mount("/binary", move |_| { + recorded.lock().unwrap().push("route"); + Response::binary_success([0, 0xff], "application/octet-stream") + }); + + let recorded = events.clone(); + + router.set_post_route_callback( + move |_: &HookContext, response: &mut Response| { + recorded.lock().unwrap().push("callback-post"); + + if let Some(content) = response.content_mut() { + content.push('!'); + } + }, + ); + serve_requests(router, move |address| { + request( + address, + "/Users/Alice", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nAlice!\nFOOTER\n", + ); + request( + address, + "/binary", + b"20 application/octet-stream\r\n\x00\xff", + ); + }) + .await; + assert_eq!( + *events.lock().unwrap(), + [ + "attach", + "async-attach", + "async-pre", + "module-pre", + "callback-pre", + "header", + "footer", + "route", + "async-post", + "module-post", + "callback-post", + "async-pre", + "module-pre", + "callback-pre", + "header", + "footer", + "route", + "async-post", + "module-post", + "callback-post", + ] + ); +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn request_framing_handles_split_characters_and_terminates_errors() { + let mut router = Router::new(); + + router.mount("/caf%C3%A9", |_| Response::success("accepted")); + router.set_error_handler(|_| Response::success("accepted")); + serve_requests(router, |address| { + let success = b"20 text/gemini; charset=utf-8; lang=en\r\naccepted\n"; + let invalid_utf8 = b"gemini://localhost/\xff"; + let invalid_url = "relative"; + let utf8_error = format!( + "59 The server (Windmark) received a bad request: {}\r\n", + std::str::from_utf8(invalid_utf8.as_slice()).unwrap_err() + ); + let url_error = format!( + "59 The server (Windmark) received a bad request: {}\r\n", + url::Url::parse(invalid_url).unwrap_err() + ); + let prefix = "gemini://localhost/"; + let maximum_url = format!("{prefix}{}", "a".repeat(1024 - prefix.len())); + + assert_eq!(maximum_url.len(), 1024); + request_chunks( + address, + &[b"gemini://localhost/caf%C", b"3%A9\r", b"\n"], + success, + ); + request_chunks(address, &[invalid_utf8, b"\r\n"], utf8_error.as_bytes()); + request_chunks( + address, + &[invalid_url.as_bytes(), b"\r\n"], + url_error.as_bytes(), + ); + request_chunks(address, &[maximum_url.as_bytes(), b"\r", b"\n"], success); + + let mut followed_by_extra_bytes = b"gemini://localhost/\r\n".to_vec(); + + followed_by_extra_bytes.extend_from_slice(&[b'x'; 1024]); + request_chunks(address, &[&followed_by_extra_bytes], success); + request_chunks( + address, + &[maximum_url.as_bytes(), b"a\r\n"], + b"59 The server (Windmark) received a bad request: request URI exceeds 1024 bytes\r\n", + ); + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn cloned_routers_retain_partials_and_running_servers_keep_their_snapshot( +) { + let mut router = Router::new(); + + router.mount("/", |_| Response::success("BODY")); + router.add_header(|_: &windmark::context::RouteContext| "HEADER".to_owned()); + router.add_footer(|_: &windmark::context::RouteContext| "FOOTER".to_owned()); + + let mut registration = router.clone(); + + serve_requests(router.clone(), move |address| { + let expected = + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nBODY\nFOOTER\n"; + + request(address, "/", expected); + registration.add_header(|_: &windmark::context::RouteContext| { + "SECOND HEADER".to_owned() + }); + registration.add_footer(|_: &windmark::context::RouteContext| { + "SECOND FOOTER".to_owned() + }); + request(address, "/", expected); + }) + .await; + + for configured in [router.clone(), router] { + serve_requests(configured, |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nSECOND HEADER\nBODY\nFOOTER\nSECOND FOOTER\n", + ); + }) + .await; + } +} + +#[derive(Default)] +struct HookGate { + state: std::sync::Mutex<(bool, Option)>, +} + +impl HookGate { + fn release(&self) { + let mut state = self.state.lock().unwrap(); + + state.0 = true; + + if let Some(waker) = state.1.take() { + waker.wake(); + } + } + + async fn wait_async(&self) { + std::future::poll_fn(|context| { + let mut state = self.state.lock().unwrap(); + + if state.0 { + std::task::Poll::Ready(()) + } else { + state.1 = Some(context.waker().clone()); + + std::task::Poll::Pending + } + }) + .await; + } +} + +struct GatedModule { + second: bool, + post_route: bool, + gate: Arc, + route_gate: Arc, + entered: std::sync::mpsc::Sender<()>, + progressed: std::sync::mpsc::Sender<()>, + events: Arc>>, +} + +impl GatedModule { + fn enter(&self, context: &HookContext, post_route: bool) -> bool { + if self.post_route != post_route { + return false; + } + + self + .events + .lock() + .unwrap() + .push((self.second, context.url.path().to_owned())); + + if self.second && context.url.path() == "/first" { + self.entered.send(()).unwrap(); + self.route_gate.release(); + + return true; + } + + if !self.second && context.url.path() == "/second" { + self.progressed.send(()).unwrap(); + } + + false + } +} + +#[async_trait::async_trait] +impl AsyncModule for GatedModule { + async fn on_pre_route(&self, context: &HookContext) { + if self.enter(context, false) { + self.gate.wait_async().await; + } + } + + async fn on_post_route(&self, context: &HookContext) { + if self.enter(context, true) { + self.gate.wait_async().await; + } + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn shared_async_modules_allow_progress_in_both_hook_phases() { + for post_route in [false, true] { + let mut router = Router::new(); + let gate = Arc::new(HookGate::default()); + let route_gate = Arc::new(HookGate::default()); + let (route_entered, route_entry) = std::sync::mpsc::channel(); + let events = Arc::new(Mutex::new(Vec::new())); + let (entered, entry) = std::sync::mpsc::channel(); + let (progressed, progress) = std::sync::mpsc::channel(); + + router.add_options(&[RouterOption::AllowConcurrentModules]); + router.set_error_handler(|_| Response::success("BODY")); + + let route_wait = route_gate.clone(); + + router.mount("/second", move |_| { + let route_wait = route_wait.clone(); + let route_entered = route_entered.clone(); + + async move { + if post_route { + route_entered.send(()).unwrap(); + route_wait.wait_async().await; + } + + Response::success("BODY") + } + }); + + for second in [false, true] { + let module = GatedModule { + second, + post_route, + gate: gate.clone(), + route_gate: route_gate.clone(), + entered: entered.clone(), + progressed: progressed.clone(), + events: events.clone(), + }; + + router.attach_async(module).await; + } + + serve_requests(router, move |address| { + let expected = b"20 text/gemini; charset=utf-8; lang=en\r\nBODY\n"; + let first_connection = connect(address); + let second_connection = connect(address); + let (start_second, second_start) = std::sync::mpsc::channel(); + let second = std::thread::spawn(move || { + second_start.recv_timeout(Duration::from_secs(5)).unwrap(); + exchange( + second_connection, + &[b"gemini://localhost/second\r\n"], + expected, + ); + }); + + if post_route { + start_second.send(()).unwrap(); + route_entry.recv_timeout(Duration::from_secs(5)).unwrap(); + } + + let first = std::thread::spawn(move || { + exchange( + first_connection, + &[b"gemini://localhost/first\r\n"], + expected, + ); + }); + + entry.recv_timeout(Duration::from_secs(5)).unwrap(); + + if !post_route { + start_second.send(()).unwrap(); + } + + let progressed = progress.recv_timeout(Duration::from_secs(5)); + + second.join().unwrap(); + gate.release(); + first.join().unwrap(); + progressed.expect("the second request did not reach the first module"); + assert_eq!( + *events.lock().unwrap(), + [ + (false, "/first".to_owned()), + (true, "/first".to_owned()), + (false, "/second".to_owned()), + (true, "/second".to_owned()) + ] + ); + }) + .await; + } +} + +struct PanickingModule { + post_route: bool, +} + +impl Module for PanickingModule { + fn on_pre_route(&self, context: &HookContext) { + assert!( + self.post_route || context.url.path() != "/panic", + "pre-route panic" + ); + } + + fn on_post_route(&self, context: &HookContext) { + assert!( + !self.post_route || context.url.path() != "/panic", + "post-route panic" + ); + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn poisoned_modules_do_not_disable_other_modules() { + for post_route in [false, true] { + let mut router = Router::new(); + let events = Arc::new(Mutex::new(Vec::new())); + + router.set_error_handler(|_| Response::success("BODY")); + router.attach(PanickingModule { + post_route, + }); + router.attach(Observer(events.clone())); + events.lock().unwrap().clear(); + serve_requests(router, move |address| { + let expected = b"20 text/gemini; charset=utf-8; lang=en\r\nBODY\n"; + + assert!(std::panic::catch_unwind(|| { + request(address, "/panic", expected) + }) + .is_err()); + events.lock().unwrap().clear(); + request(address, "/healthy", expected); + assert_eq!(*events.lock().unwrap(), ["module-pre", "module-post"]); + }) + .await; + } +} + +mod named_routes { + use rossweisse::route; + use windmark::{context::RouteContext, response::Response}; + + #[rossweisse::router] + pub struct Capsule; + + #[rossweisse::router] + impl Capsule { + #[rossweisse::route(index)] + pub fn index(_: RouteContext) -> Response { Response::success("INDEX") } + + #[route] + pub fn about(_: RouteContext) -> Response { Response::success("ABOUT") } + + #[route] + pub fn __router_index(_: RouteContext) -> Response { + Response::success("ORDINARY") + } + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn index_route_attributes_preserve_names_and_select_the_root_path() { + let mut capsule = named_routes::Capsule::new(); + + serve_requests(capsule.router().clone(), |address| { + for (path, body) in [ + ("/", "INDEX"), + ("/about", "ABOUT"), + ("/__router_index", "ORDINARY"), + ] { + request( + address, + path, + format!("20 text/gemini; charset=utf-8; lang=en\r\n{body}\n") + .as_bytes(), + ); + } + }) + .await; +} + +async fn pause(duration: Duration) { + #[cfg(feature = "tokio")] + tokio::time::sleep(duration).await; + #[cfg(feature = "async-std")] + async_std::task::sleep(duration).await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn connection_deadlines_release_stalled_handshakes_and_requests() { + let mut router = Router::new(); + let mut limits = windmark::router::ConnectionLimits::default(); + let timeout = Duration::from_secs(1); + + limits.max_connections = std::num::NonZeroUsize::new(1); + limits.handshake_timeout = Some(timeout); + limits.request_timeout = Some(timeout); + limits.shutdown_timeout = Some(timeout); + + router.set_connection_limits(limits); + router.mount("/", |_| Response::success("ready")); + serve_requests(router, |address| { + let mut stalled = connect_tcp(address); + + stalled + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + let result = stalled.read(&mut [0]); + + assert!( + matches!(result, Ok(0)) + || result.is_err_and(|error| { + error.kind() == std::io::ErrorKind::ConnectionReset + }) + ); + + let mut stalled = connect(address); + + stalled.write_all(b"gemini://localhost/").unwrap(); + + let started = Instant::now(); + let result = stalled.read(&mut [0]); + + assert!(result.is_err() || matches!(result, Ok(0))); + assert!(started.elapsed() < Duration::from_secs(4)); + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn connection_admission_caps_active_handlers() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let observed = peak.clone(); + let mut router = Router::new(); + let mut limits = windmark::router::ConnectionLimits::default(); + + limits.max_connections = std::num::NonZeroUsize::new(1); + + router.set_connection_limits(limits); + router.mount("/", move |_| { + let active = active.clone(); + let peak = peak.clone(); + + async move { + let count = active.fetch_add(1, Ordering::SeqCst) + 1; + + peak.fetch_max(count, Ordering::SeqCst); + pause(Duration::from_millis(40)).await; + active.fetch_sub(1, Ordering::SeqCst); + + Response::success("ready") + } + }); + serve_requests(router, move |address| { + let start = Arc::new(std::sync::Barrier::new(3)); + let clients: Vec<_> = (0..2) + .map(|_| { + let start = start.clone(); + + std::thread::spawn(move || { + start.wait(); + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + }) + }) + .collect(); + + start.wait(); + + for client in clients { + client.join().unwrap(); + } + + assert_eq!(observed.load(Ordering::SeqCst), 1); + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn shutdown_drains_completed_handlers_and_cancels_unfinished_handlers() { + use std::sync::atomic::{AtomicBool, Ordering}; + + struct Completion(Arc); + + impl Drop for Completion { + fn drop(&mut self) { self.0.store(true, Ordering::SeqCst); } + } + + for finish in [true, false] { + let started = Arc::new(AtomicBool::new(false)); + let stopping = started.clone(); + let dropped = Arc::new(AtomicBool::new(false)); + let observed = dropped.clone(); + let mut router = Router::new(); + let mut limits = windmark::router::ConnectionLimits::default(); + + limits.drain_timeout = Duration::from_millis(100); + + router.set_connection_limits(limits); + router.mount("/", move |_| { + let completion = Completion(dropped.clone()); + let started = started.clone(); + + async move { + started.store(true, Ordering::SeqCst); + + if finish { + pause(Duration::from_millis(10)).await; + } else { + std::future::pending::<()>().await; + } + + drop(completion); + + Response::success("finished") + } + }); + serve_requests_until( + router, + move |address| { + let mut stream = connect(address); + let started = Instant::now(); + + if finish { + exchange( + stream, + &[b"gemini://localhost/\r\n"], + b"20 text/gemini; charset=utf-8; lang=en\r\nfinished\n", + ); + } else { + stream.write_all(b"gemini://localhost/\r\n").unwrap(); + + let result = stream.read(&mut [0]); + + assert!(result.is_err() || matches!(result, Ok(0))); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + assert!(observed.load(Ordering::SeqCst)); + }, + async move { + while !stopping.load(Ordering::SeqCst) { + pause(Duration::from_millis(1)).await; + } + }, + ) + .await; + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn invalid_response_headers_become_complete_failure_responses() { + let mut router = Router::new(); + + router.mount("/status", |_| Response::new(99, "invalid")); + router.mount("/metadata", |_| { + let mut response = Response::success("must not escape"); + + response.with_mime("text/gemini\r\nINJECTED"); + + response + }); + serve_requests(router, |address| { + for path in ["/status", "/metadata"] { + request( + address, + path, + b"40 The server could not encode the response\r\n", + ); + } + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn startup_preserves_an_existing_logger() { + struct Logger; + + impl log::Log for Logger { + fn enabled(&self, _: &log::Metadata<'_>) -> bool { false } + + fn log(&self, _: &log::Record<'_>) {} + + fn flush(&self) {} + } + + static LOGGER: Logger = Logger; + + log::set_logger(&LOGGER).unwrap(); + + for _ in 0..2 { + let mut router = Router::new(); + + router.mount("/", |_| Response::success("ready")); + serve_requests(router, |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + }) + .await; + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn write_deadlines_release_connections_when_clients_stop_reading() { + use std::num::NonZeroUsize; + use windmark::router::ConnectionLimits; + + let mut limits = ConnectionLimits::default(); + + limits.max_connections = NonZeroUsize::new(1); + limits.write_timeout = Some(Duration::from_millis(50)); + + let mut router = Router::new(); + + router.set_connection_limits(limits); + router.mount("/large", |_| { + Response::binary_success( + vec![0; 32 * 1024 * 1024], + "application/octet-stream", + ) + }); + router.mount("/", |_| Response::success("ready")); + serve_requests(router, |address| { + let mut stalled = connect(address); + + stalled.write_all(b"gemini://localhost/large\r\n").unwrap(); + + let started = Instant::now(); + + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + assert!(started.elapsed() < Duration::from_secs(2)); + drop(stalled); + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn bound_servers_own_their_ports_and_report_binding_errors() { + let mut router = Router::new(); + + router.set_listener_address("127.0.0.1"); + router.set_port(0); + router.set_ssl_acceptor(acceptor()); + + let server = router.bind().await.unwrap(); + let address = server.local_addr().unwrap(); + + assert_ne!(address.port(), 0); + router.set_port(address.port()); + assert!(router.bind().await.is_err()); + drop(server); + + let rebound = router.bind().await.unwrap(); + + assert_eq!(rebound.local_addr().unwrap(), address); + rebound.run_until(std::future::ready(())).await; + + let listener = TcpListener::bind(address).unwrap(); + + assert_eq!(listener.local_addr().unwrap(), address); +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn binding_captures_routes_but_preserves_shared_module_registration() { + let mut router = Router::new(); + let events: Events = Arc::default(); + + router.set_listener_address("127.0.0.1"); + router.set_port(0); + router.set_ssl_acceptor(acceptor()); + router.set_error_handler(|_| Response::success("before")); + + let first = router.bind().await.unwrap(); + + router.mount("/", |_| Response::success("after")); + + let second = router.bind().await.unwrap(); + + assert_ne!(first.local_addr().unwrap(), second.local_addr().unwrap()); + router.attach(Observer(events.clone())); + drop(router); + serve_bound_requests( + first, + |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nbefore\n", + ); + }, + std::future::pending(), + ) + .await; + serve_bound_requests( + second, + |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nafter\n", + ); + }, + std::future::pending(), + ) + .await; + assert_eq!( + *events.lock().unwrap(), + [ + "attach", + "module-pre", + "module-post", + "module-pre", + "module-post" + ] + ); +}