From 97583aab10f9cdffa3916d6765a09df30d6b9ac6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 9 Oct 2026 17:16:39 +0300 Subject: [PATCH 01/19] fix(transcode): route custom verbs after a path variable A google.api.http template whose last segment is a variable followed by a custom verb (`post: "/v2/ops/{operation}:cancel"`, AIP-136) is served instead of panicking the router at startup. Bindings that differ only by verb reach their own RPCs, and a template the router cannot match is left out with an error in the log instead of a panic. - Templates convert in transcode/path.rs: a verb after a variable or wildcard is split off the mounted path, a verb after a literal stays in it, and a path is checked against matchit (whole-segment variables, the 25-variable limit) before registration. A multi-segment field template before the last segment is spelled out in the router path; `**` must be the last part of the path; a nested variable is refused. - transcode/table.rs: bindings of one path shape share one route. A request's verb is its last segment from the first unencoded `:`; a bound verb owns its URL, an unbound one stays in the last variable. Escapes compare by octet, constrained bindings rank before open ones, and lower-ranked paths answer what the best one refuses. - A variable over several segments keeps `%2F` encoded; one segment decodes it, and an escaped template literal is taken from the request. - The binding is chosen before every layer of the routes: a URL no binding answers goes to the proxy's other routes, then to the fallback (or 404), preflight included; one answered for other methods is 405. - Guards take the methods of the transcoded bindings; a route answering every method lets a scope over its own traffic name any method. - OpenAPI documents only the routes the router mounts. - README documents custom verbs; CONTRIBUTING.md, SECURITY.md and the CLA note are added. Closes #138 --- CONTRIBUTING.md | 62 ++++ Cargo.toml | 7 + README.md | 50 ++- SECURITY.md | 20 ++ src/client_address.rs | 5 +- src/guard.rs | 50 ++- src/guard/tests.rs | 47 ++- src/lib.rs | 84 +++-- src/openapi.rs | 9 +- src/openapi/tests.rs | 54 +++ src/service.rs | 52 ++- src/transcode/mod.rs | 744 ++++++++++++++++++++++++-------------- src/transcode/path.rs | 479 +++++++++++++++++++++++++ src/transcode/table.rs | 674 +++++++++++++++++++++++++++++++++++ src/transcode/tests.rs | 791 ++++++++++++++++++++++++++++++++++++++++- tests/custom_verbs.rs | 663 ++++++++++++++++++++++++++++++++++ tests/hooks.rs | 16 + 17 files changed, 3458 insertions(+), 349 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 src/transcode/path.rs create mode 100644 src/transcode/table.rs create mode 100644 tests/custom_verbs.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9e5edb0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing to structured-proxy + +Bug reports, fixes, documentation and new capabilities are welcome. + +## Development setup + +```bash +git clone https://github.com/structured-world/structured-proxy.git +cd structured-proxy +cargo build --features cli +``` + +Tests run under [cargo-nextest](https://nexte.st/), which is not part of the +Rust toolchain: + +```bash +cargo install cargo-nextest --locked +``` + +Before opening a pull request, run what CI runs. The crate builds with each +crypto backend, without one and with both, and the code differs between them: + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --features cli -- -D warnings +cargo clippy --all-targets --no-default-features --features cli -- -D warnings +cargo clippy --all-targets --all-features -- -D warnings +cargo nextest run --features cli +cargo nextest run --no-default-features --features cli +cargo nextest run --all-features +cargo test --doc --all-features +``` + +The rate-limit reconciliation test runs against Redis when +`SHIELD_REDIS_TEST_URL` names one (`redis://127.0.0.1:6379/`). + +## Pull requests + +1. Create a branch from `main`. +2. Make the change, with tests. A bug fix comes with a test that fails without + it. +3. Write commit messages and the pull request title in the + [Conventional Commits](https://www.conventionalcommits.org/) form; pull + requests are squash-merged, so the title becomes the commit on `main`. +4. A change to what the proxy serves or how it is configured updates the + README in the same pull request. + +## Contributor License Agreement (CLA) + +Before a first pull request can be merged, you sign the Structured World +Contributor License Agreement once, at . It covers +every repository of the organisation and takes a minute: sign in with GitHub, +confirm your e-mail address, sign. The `CLA` status on your pull request then +turns green by itself. + +You keep the copyright in your contribution. If you contribute as part of your +job, your employer may also need to sign the corporate agreement; the page +above explains when. + +## Security + +Do not report vulnerabilities in public issues; see [SECURITY.md](SECURITY.md). diff --git a/Cargo.toml b/Cargo.toml index dac873b..f9c93ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,13 @@ hyper-util = { version = "0.1", features = ["server-auto", "server-graceful", "s # in the tree through hyper-util and tonic. hyper = "1" tokio-util = { version = "0.7.16", features = ["rt"] } +# The router axum matches paths with: a transcoded template is checked with it +# before axum registers it, where a path it rejects would panic. Already in the +# tree through axum, which pins the version this resolves to. +matchit = "0.8" +# Decodes the path parameters of a custom verb bound on another route the +# request matches, as axum decodes its own; already in the tree through axum. +percent-encoding = "2" # gRPC client (to upstream service). `tls-connect-info` gives a rustls server # stream the connection record (client certificates) a tonic handler reads, so diff --git a/README.md b/README.md index f4baff5..8c143e4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ tonic services. - REST routes from the `google.api.http` annotations in your protos: path, query and JSON or form body mapped onto the request message ([Request mapping](#request-mapping)), `response_body`, - `additional_bindings`, and `custom` rules for any HTTP method + `additional_bindings`, custom verbs (`/v1/{name}:cancel`), and `custom` + rules for any HTTP method - Server streaming as NDJSON, or Server-Sent Events when the client asks for `text/event-stream` - Errors as `google.rpc.Status` JSON with the standard HTTP status mapping and @@ -380,6 +381,48 @@ body, as the route's `google.api.http` rule says: A value that is not valid for its field, or two members of one `oneof`, is answered with `INVALID_ARGUMENT` (400) before the upstream is called. +A variable bound to a multi-segment template (`{name=shelves/*/books/*}`, +AIP-127) takes only a value of that shape: its literals, one segment per `*`, +any number for `**`. `**` must be the last part of the path: a template that +uses it before the last segment, or puts another segment after it, is left +out with an error in the log. Of the bindings of one router path, those such +a template or an escaped literal constrains are tried before the open ones. The value of such a variable, and of `{name=**}`, is +percent-decoded except for `%2F`, which reaches the field as received, so an +encoded slash stays distinct from a segment boundary; a single-segment +variable decodes it. A literal of such a template that holds a percent-escape +matches whatever the case of its hex digits, so to the router it is a +variable: like any variable, it cannot share its position with a `**` of +another binding, and the later of the two is left out with an error. + +**Custom verbs.** A path template may end in a verb, as AIP-136 custom methods +do (`post: "/v1/{name=operations/*}:cancel"`): + +- A request's verb is its last segment from the first unencoded colon: a + client percent-encodes the reserved characters of a variable's value, and a + verb writes its own reserved characters encoded too + (`:run%3Anow`), so neither holds a raw `:`. A percent-encoded colon (`%3A`) + in a request is part of a value, never the verb's delimiter. +- A verb some binding of the request's path binds owns that URL: only the + bindings of that verb answer it, each reaching its own RPC. A verb no + binding binds stays part of the last variable (`/v1/items/a:b` is the item + `a:b`), as Envoy's transcoder and grpc-gateway treat an unbound verb. +- A template ending in a literal matches its URL exactly and wins over a + variable with the same verb: `get: "/v1/jobs/special:cancel"` answers that + URL ahead of `post: "/v1/jobs/{name}:cancel"`, as a static route does. +- `**` before a verb may match no segment: `/v1/{name=**}:purge` answers + `/v1/:purge` with an empty `name`. +- A path with a verb after its variable answers every method, so an extra + route of yours on the same path stops the proxy at startup. +- A URL that bindings answer, but none with the request's method, is `405`, + with their methods in `Allow`. A URL that no binding answers, its field + template or verb not fitting, is no transcoded request: another route of + the proxy (an extra route of yours) takes it, else it is `404`, or your + fallback's, preflight included. + +A template the router cannot match, such as text around a variable within one +segment (`/v1/a{name}b`), is left out with an error in the log; the other +routes still serve. + ## Client address The proxy works out who the client is once per request, before any guard @@ -1181,7 +1224,8 @@ A plain TCP connection passes `stream.connect_info()` directly ### What passes through A request no route matches gets `404`, or goes to your own service with -`ProxyService::with_fallback(my_axum_app)`. Native gRPC calls and the fallback +`ProxyService::with_fallback(my_axum_app)`; so does a URL whose path a +transcoded route matches but no binding answers. Native gRPC calls and the fallback pass only the guards whose scope names `grpc` or `fallback` (see [Guards and scopes](#guards-and-scopes)); by default none does. The fallback keeps its own CORS and tracing. @@ -1467,3 +1511,5 @@ USDT (TRC-20): `TFDsezHa1cBkoeZT5q2T49Wp66K8t2DmdA` ## License Apache-2.0 + +Contributions are accepted under the [Structured World Contributor License Agreement](https://sw.foundation/cla); see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..875988a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security policy + +structured-proxy faces untrusted clients: a way past a guard (auth, rate +limits, maintenance), a request that reaches the upstream in a form it should +not, a crash, a hang or unbounded memory on any request is a security issue. + +## Reporting a vulnerability + +Report it privately through +[GitHub private vulnerability reporting](https://github.com/structured-world/structured-proxy/security/advisories/new), +not in a public issue. Include the request or configuration that triggers it, +the version or commit, the features enabled, and what you observed. + +You will get an answer within a few days. A confirmed issue is fixed in a new +release, and the advisory is published with credit to you unless you prefer +otherwise. + +## Supported versions + +Fixes go into the latest release only. diff --git a/src/client_address.rs b/src/client_address.rs index 270c22f..a8cbc4a 100644 --- a/src/client_address.rs +++ b/src/client_address.rs @@ -358,8 +358,9 @@ impl Resolver { self.resolve_noting(peer, headers, None) } - /// [`resolve`](Self::resolve), noting in `hops` the trusted proxies - /// between the client and this proxy, nearest first. + /// The client address of a request from `peer` carrying `headers` (what + /// [`apply`](Self::apply) puts on it), noting in `hops` the trusted + /// proxies between the client and this proxy, nearest first. fn resolve_noting( &self, peer: Option, diff --git a/src/guard.rs b/src/guard.rs index 15be9bf..b81ae48 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -140,7 +140,7 @@ pub(crate) struct Scope { impl Scope { /// Compile `config` for the guard named `what`, with `default` traffic /// when the config names none; `routed` are the methods the proxy's - /// routes answer beyond the standard ones (`custom` rules, extra routes). + /// routes answer beyond the standard ones. /// /// # Errors /// A scope that covers no traffic, a path glob that is relative or does @@ -150,7 +150,7 @@ impl Scope { config: Option<&ScopeConfig>, default: &[Traffic], what: &str, - routed: &[Method], + routed: Routed<'_>, ) -> Result, String> { let traffic = config.and_then(|c| c.traffic.as_deref()).unwrap_or(default); let classes = traffic.iter().fold(0, |acc, t| acc | traffic_bits(*t)); @@ -188,7 +188,7 @@ impl Scope { Some( names .iter() - .map(|name| scope_method(name, what, classes & FALLBACK != 0, routed)) + .map(|name| scope_method(name, what, classes, routed)) .collect::, _>>()?, ) }; @@ -232,16 +232,35 @@ const STANDARD_METHODS: [Method; 9] = [ Method::PATCH, ]; -/// One `scope.methods` entry of the guard `what`: a method some request can -/// carry, or an error. A method no route answers would match no request and -/// leave the guard covering nothing, unless the scope covers the fallback, -/// whose methods are the embedder's. -fn scope_method( - name: &str, - what: &str, - covers_fallback: bool, - routed: &[Method], -) -> Result { +/// The methods the proxy's routes answer beyond the standard ones. +#[derive(Clone, Copy)] +pub(crate) struct Routed<'a> { + /// The methods routes name: `custom` rules, extra routes. + methods: &'a [Method], + /// The classes with a route answering every method. + every: u8, +} + +impl<'a> Routed<'a> { + /// Routes that answer `methods`. + pub(crate) fn new(methods: &'a [Method]) -> Self { + Self { methods, every: 0 } + } + + /// With a route of `class` that answers every method: a `custom` `*` + /// rule, the verify endpoint. It answers only a scope covering its class. + #[must_use] + pub(crate) fn every(mut self, class: Class) -> Self { + self.every |= class.bit(); + self + } +} + +/// One `scope.methods` entry of the guard `what`, whose scope covers +/// `classes`: a method some request can carry, or an error. A method no route +/// answers would match no request and leave the guard covering nothing, +/// unless the scope covers the fallback, whose methods are the embedder's. +fn scope_method(name: &str, what: &str, classes: u8, routed: Routed<'_>) -> Result { if name == "*" { return Err(format!( "{what}.scope.methods entry \"*\" is not a method: leave methods out to cover every one" @@ -249,7 +268,10 @@ fn scope_method( } let method = Method::from_bytes(name.to_ascii_uppercase().as_bytes()) .map_err(|_| format!("{what}.scope.methods entry {name:?} is not a method"))?; - if covers_fallback || STANDARD_METHODS.contains(&method) || routed.contains(&method) { + if classes & (FALLBACK | routed.every) != 0 + || STANDARD_METHODS.contains(&method) + || routed.methods.contains(&method) + { Ok(method) } else { Err(format!( diff --git a/src/guard/tests.rs b/src/guard/tests.rs index 6b96bf3..d43749b 100644 --- a/src/guard/tests.rs +++ b/src/guard/tests.rs @@ -1,6 +1,12 @@ use super::*; use crate::upstream::GrpcProtocol; +/// No route answering a method beyond the standard ones. +const STANDARD: Routed<'static> = Routed { + methods: &[], + every: 0, +}; + fn maintenance() -> Maintenance { Maintenance { exempt: vec![ @@ -45,7 +51,13 @@ fn request(method: &str, path: &str) -> http::Request<()> { #[test] fn a_scope_without_traffic_takes_the_default() { - let scope = Scope::compile(None, &[Traffic::Transcoded, Traffic::Grpc], "shield", &[]).unwrap(); + let scope = Scope::compile( + None, + &[Traffic::Transcoded, Traffic::Grpc], + "shield", + STANDARD, + ) + .unwrap(); assert!(scope.covers(Class::Transcoded)); assert!(scope.covers(Class::Grpc)); assert!(!scope.covers(Class::Endpoints)); @@ -56,7 +68,7 @@ fn a_scope_without_traffic_takes_the_default() { #[test] fn all_traffic_covers_every_class() { let config = ScopeConfig::traffic([Traffic::All]); - let scope = Scope::compile(Some(&config), &[], "shield", &[]).unwrap(); + let scope = Scope::compile(Some(&config), &[], "shield", STANDARD).unwrap(); for class in [ Class::Transcoded, Class::Endpoints, @@ -72,7 +84,7 @@ fn all_traffic_covers_every_class() { fn a_scope_that_covers_no_traffic_is_an_error() { // An empty list would mount the guard nowhere; a typo, not a choice. let config = ScopeConfig::traffic([]); - let err = Scope::compile(Some(&config), &[Traffic::Transcoded], "auth", &[]).unwrap_err(); + let err = Scope::compile(Some(&config), &[Traffic::Transcoded], "auth", STANDARD).unwrap_err(); assert!(err.contains("auth.scope.traffic"), "{err}"); } @@ -82,14 +94,16 @@ fn a_relative_or_invalid_path_glob_is_an_error() { paths: vec!["v1/**".into()], ..ScopeConfig::default() }; - let err = Scope::compile(Some(&relative), &[Traffic::Transcoded], "shield", &[]).unwrap_err(); + let err = + Scope::compile(Some(&relative), &[Traffic::Transcoded], "shield", STANDARD).unwrap_err(); assert!(err.contains("must start with '/'"), "{err}"); let invalid = ScopeConfig { paths: vec!["/v1/[".into()], ..ScopeConfig::default() }; - let err = Scope::compile(Some(&invalid), &[Traffic::Transcoded], "shield", &[]).unwrap_err(); + let err = + Scope::compile(Some(&invalid), &[Traffic::Transcoded], "shield", STANDARD).unwrap_err(); assert!(err.contains("is invalid"), "{err}"); } @@ -99,7 +113,8 @@ fn an_invalid_method_is_an_error() { methods: vec!["GE T".into()], ..ScopeConfig::default() }; - let err = Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", &[]).unwrap_err(); + let err = + Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", STANDARD).unwrap_err(); assert!(err.contains("is not a method"), "{err}"); } @@ -115,7 +130,7 @@ fn a_catch_all_method_is_an_error() { // `*` means every method in route policies; here it would match no // request and leave the guard covering nothing. let config = methods(&["*"], Traffic::Transcoded); - let err = Scope::compile(Some(&config), &[], "auth", &[]).unwrap_err(); + let err = Scope::compile(Some(&config), &[], "auth", STANDARD).unwrap_err(); assert!(err.contains("leave methods out"), "{err}"); } @@ -123,7 +138,7 @@ fn a_catch_all_method_is_an_error() { fn a_method_no_route_answers_is_an_error() { // A typo matches no request, and the guard would silently cover nothing. let config = methods(&["PSOT"], Traffic::Transcoded); - let err = Scope::compile(Some(&config), &[], "shield", &[]).unwrap_err(); + let err = Scope::compile(Some(&config), &[], "shield", STANDARD).unwrap_err(); assert!(err.contains("\"PSOT\""), "{err}"); } @@ -131,8 +146,16 @@ fn a_method_no_route_answers_is_an_error() { fn an_extension_method_a_route_answers_is_accepted() { let config = methods(&["PROPFIND"], Traffic::Transcoded); let routed = [Method::from_bytes(b"PROPFIND").unwrap()]; - let scope = Scope::compile(Some(&config), &[], "shield", &routed).unwrap(); + let scope = Scope::compile(Some(&config), &[], "shield", Routed::new(&routed)).unwrap(); + assert!(scope.matches(&request("PROPFIND", "/dav"))); + // A transcoded route answering every method answers it too, but only + // for a scope over transcoded traffic. + let every = STANDARD.every(Class::Transcoded); + let scope = Scope::compile(Some(&config), &[], "shield", every).unwrap(); assert!(scope.matches(&request("PROPFIND", "/dav"))); + let endpoints = methods(&["PROPFIND"], Traffic::Endpoints); + let err = Scope::compile(Some(&endpoints), &[], "shield", every).unwrap_err(); + assert!(err.contains("\"PROPFIND\""), "{err}"); } #[test] @@ -140,7 +163,7 @@ fn any_method_token_is_accepted_for_the_fallback() { // The fallback is the embedder's own service: which methods it answers // is not the proxy's to know. let config = methods(&["PROPFIND"], Traffic::Fallback); - assert!(Scope::compile(Some(&config), &[], "shield", &[]).is_ok()); + assert!(Scope::compile(Some(&config), &[], "shield", STANDARD).is_ok()); } #[test] @@ -150,7 +173,7 @@ fn paths_and_methods_narrow_the_requests_a_scope_matches() { methods: vec!["post".into()], ..ScopeConfig::default() }; - let scope = Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", &[]).unwrap(); + let scope = Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", STANDARD).unwrap(); assert!(scope.narrows()); assert!(scope.matches(&request("POST", "/v1/orders/42"))); // Methods compare as tokens, case aside. @@ -179,7 +202,7 @@ async fn a_narrowed_guard_readies_the_branch_it_calls() { }; let scoped = Scoped { layer: tower::layer::layer_fn(|inner| inner), - scope: Scope::compile(Some(&config), &[], "maintenance", &[]).unwrap(), + scope: Scope::compile(Some(&config), &[], "maintenance", STANDARD).unwrap(), } .layer(fallback); for path in ["/other", "/guarded", "/other"] { diff --git a/src/lib.rs b/src/lib.rs index 1c8c351..9e0d7fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -825,7 +825,7 @@ impl ProxyServer { let cors = self.build_cors()?; // Build transcoding routes from descriptor pool. - let transcode_routes = transcode::routes_with_options( + let (transcode_routes, chooser) = transcode::routes_and_chooser( &pool, &self.config.aliases, &self.transcode.clone().with_selection(selection.clone()), @@ -841,7 +841,24 @@ impl ProxyServer { let forward_auth = auth.as_ref().and_then(|built| { auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone()) }); - let guards = Arc::new(self.guards(resolver, auth, maintenance_exempt, &mounted)?); + // The methods a route answers: `*` is not a method. It marks the + // verify endpoint and a `custom` `*` rule, which answer every method, + // and a transcoded path with verbs, whose bindings still name theirs. + let mut methods: Vec = mounted + .iter() + .filter(|(method, _)| method != "*") + .filter_map(|(method, _)| http::Method::from_bytes(method.as_bytes()).ok()) + .collect(); + let bound = transcode::bound_methods(&pool, &self.config.aliases, &selection); + methods.extend(bound.methods); + let mut routed = guard::Routed::new(&methods); + if bound.every { + routed = routed.every(guard::Class::Transcoded); + } + if verify_path.is_some() { + routed = routed.every(guard::Class::Verify); + } + let guards = Arc::new(self.guards(resolver, auth, maintenance_exempt, routed)?); // Health routes. Paths are configurable; the whole group is skippable. let health_routes = if self.config.health.enabled { @@ -965,18 +982,45 @@ impl ProxyServer { // Each class of traffic behind the guards that cover it, so a request // runs only the guards of its own class. A path no route answers // reaches the plain 404 (or the fallback's own guards). - let router = Router::new() - .merge(guards.router(transcode_routes, guard::Class::Transcoded)) - .merge(guards.router(endpoints, guard::Class::Endpoints)) - .merge(guards.router(verify, guard::Class::Verify)) - // Before every guard: they and the handlers read its result. - .layer(client_address::ClientAddressLayer::with( - guards.client_address.clone(), - )) - .layer(TraceLayer::new_for_http()); - // Outermost: wraps every enforcement layer so short-circuited - // responses keep CORS headers, and answers preflight before auth. - let router = cors::layer(router, cors.clone()).with_state(state); + let endpoints = guards.router(endpoints, guard::Class::Endpoints); + let verify = guards.router(verify, guard::Class::Verify); + let layered = |router: Router>| { + let router = router + // Before every guard: they and the handlers read its result. + .layer(client_address::ClientAddressLayer::with( + guards.client_address.clone(), + )) + .layer(TraceLayer::new_for_http()); + // Outermost: wraps every enforcement layer so short-circuited + // responses keep CORS headers, and answers preflight before auth. + cors::layer(router, cors.clone()) + }; + let router = layered( + Router::new() + .merge(guards.router(transcode_routes, guard::Class::Transcoded)) + .merge(endpoints.clone()) + .merge(verify.clone()), + ); + // The binding is chosen before every layer: a URL no binding answers + // (its field template, its custom verb) is no transcoded request. It + // goes to the proxy's other routes, which a router without the + // transcoded ones ranks for it, and on to the fallback (or the plain + // 404) when none answers it either, past the layers like any path no + // route answers. That fallback is set after the layers, so it has none. + let router = match chooser { + Some(chooser) => { + let elsewhere = layered(Router::new().merge(endpoints).merge(verify)) + .fallback(|request: axum::extract::Request| async { + transcode::unrouted(request) + }) + .with_state(state.clone()); + chooser + .with_elsewhere(guard::BoxedService::new(elsewhere)) + .layer(router) + } + None => router, + }; + let router = router.with_state(state); Ok((router, cors, guards)) } @@ -984,7 +1028,7 @@ impl ProxyServer { /// The guards the configuration and the hooks turn on, each with its /// scope, after the client-address resolution `resolver`; /// `maintenance_exempt` lists the paths maintenance mode leaves - /// reachable, `mounted` the `(method, path)` of every route. + /// reachable, `routed` the methods the routes answer. /// /// # Errors /// @@ -996,17 +1040,11 @@ impl ProxyServer { resolver: Arc, auth: Option>, maintenance_exempt: Vec, - mounted: &[(String, String)], + routed: guard::Routed<'_>, ) -> anyhow::Result { use config::Traffic::{Endpoints, Grpc, Transcoded}; - // `*` marks a route that answers every method, not a method. - let routed: Vec = mounted - .iter() - .filter(|(method, _)| method != "*") - .filter_map(|(method, _)| http::Method::from_bytes(method.as_bytes()).ok()) - .collect(); let scope = |config: Option<&ScopeConfig>, default: &[config::Traffic], what: &str| { - guard::Scope::compile(config, default, what, &routed).map_err(anyhow::Error::msg) + guard::Scope::compile(config, default, what, routed).map_err(anyhow::Error::msg) }; // A guard never sets what the client-address forwarding writes. let reserved: Arc<[http::HeaderName]> = resolver.configured_headers().cloned().collect(); diff --git a/src/openapi.rs b/src/openapi.rs index a3722a8..a9343fd 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -11,10 +11,10 @@ use prost_reflect::{DescriptorPool, FieldDescriptor, Kind, MessageDescriptor, Me use serde_json::{json, Map, Value}; use crate::config::{AliasConfig, OpenApiConfig}; -use crate::transcode::httpbody; use crate::transcode::request::BodyMapping; use crate::transcode::rule::{self, HttpBinding, RouteMethod}; use crate::transcode::RpcSelection; +use crate::transcode::{self, httpbody, path}; /// The operations an OpenAPI 3.0 path item can hold, in the order a `*` rule /// lists them. @@ -38,6 +38,9 @@ pub fn generate( let mut tags = Vec::new(); let mut operation_ids = HashSet::new(); let http_ext = pool.get_extension_by_name("google.api.http"); + // The router paths the routes mount: a template the router cannot match, + // or one it refuses beside another, would promise a URL that is 404. + let mounted = transcode::mounted_shapes(pool, aliases, selection); for service in pool.services() { if !selection.is_all() && !service.methods().any(|m| selection.selects(&m)) { @@ -86,6 +89,10 @@ pub fn generate( } } for path in targets { + let mount = path::MountedPath::new(&path); + if mount.routable().is_err() || !mounted.contains(&mount.shape) { + continue; + } let mut operation = operation.clone(); if http_method == "head" { strip_response_content(&mut operation); diff --git a/src/openapi/tests.rs b/src/openapi/tests.rs index 0f0eb74..2490b5e 100644 --- a/src/openapi/tests.rs +++ b/src/openapi/tests.rs @@ -184,6 +184,18 @@ service Api { rpc Raw(google.api.HttpBody) returns (google.api.HttpBody) { option (google.api.http) = { post: "/v1/raw" body: "*" }; } + rpc Archive(Item) returns (Item) { + option (google.api.http) = { post: "/v1/items/{name}:archive" }; + } + rpc Broken(Item) returns (Item) { + option (google.api.http) = { get: "/v1/broken/a{name}b" }; + } + rpc Shelved(Item) returns (Item) { + option (google.api.http) = { get: "/v1/shelf/{name=foo%3Abar/*}/books" }; + } + rpc Shadowed(Item) returns (Item) { + option (google.api.http) = { get: "/v1/shelf/{name=**}" }; + } } "#; @@ -224,6 +236,48 @@ fn custom_head_rule_is_a_head_operation() { assert_eq!(item["head"]["parameters"][0]["in"], "path"); } +#[test] +fn custom_verb_after_a_variable_keeps_its_path_and_parameter() { + // OpenAPI path templating allows a template expression inside a segment, + // so the verb stays in the path and the variable is a path parameter. + let spec = spec(&[]); + let archive = &spec["paths"]["/v1/items/{name}:archive"]["post"]; + assert_eq!(archive["operationId"], "Api.Archive"); + let params = archive["parameters"].as_array().unwrap(); + let path_params: Vec<&Value> = params.iter().filter(|p| p["in"] == "path").collect(); + assert_eq!(path_params.len(), 1); + assert_eq!(path_params[0]["name"], "name"); + // No path item without the verb comes from this binding. + assert!(spec["paths"]["/v1/items/{name}"].get("post").is_none()); +} + +#[test] +fn a_route_the_router_refuses_beside_another_is_not_documented() { + // The escaped literal is a variable to the router, which then refuses the + // catch-all after it at that position: only the route it serves is in + // the spec. + let spec = spec(&[]); + let paths = spec["paths"].as_object().unwrap(); + assert!( + paths.contains_key("/v1/shelf/{name=foo%3Abar/*}/books"), + "{paths:?}" + ); + assert!(!paths.contains_key("/v1/shelf/{name=**}"), "{paths:?}"); +} + +#[test] +fn a_template_the_router_cannot_serve_is_not_documented() { + // The router skips `a{name}b`; the spec must not promise it either. + let spec = spec(&[]); + let paths = spec["paths"].as_object().unwrap(); + assert!(!paths.contains_key("/v1/broken/a{name}b"), "{paths:?}"); + assert!(!paths.values().any(|item| item + .as_object() + .unwrap() + .values() + .any(|op| op["operationId"] == "Api.Broken"))); +} + #[test] fn star_rule_is_listed_under_every_method_with_distinct_ids() { let spec = spec(&[]); diff --git a/src/service.rs b/src/service.rs index 3690311..525c006 100644 --- a/src/service.rs +++ b/src/service.rs @@ -48,7 +48,7 @@ use crate::upstream::{ /// only the guards whose scope names them (`grpc`, `fallback`); a guard's /// rejection of a gRPC call is a gRPC status. /// -/// Serve it with [`serve`](crate::serve) or +/// Serve it with [`serve`](crate::serve()) or /// [`serve_with`](crate::serve_with) (TLS, a connection limit), or hand it to /// any server that takes a tower service of `http` types: a Unix socket, an /// existing hyper or axum server. Native gRPC needs HTTP/2 on that server @@ -215,6 +215,27 @@ impl Service for Forward { } } +/// Hands a URL the transcoded routes matched by path, but no binding answers, +/// to the fallback instead of their `404`. +async fn redispatch( + axum::extract::State(fallback): axum::extract::State, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let response = next.run(request).await; + match response + .extensions() + .get::() + .and_then(crate::transcode::Unrouted::take) + { + Some(request) => match fallback.oneshot(request).await { + Ok(response) => response, + Err(never) => match never {}, + }, + None => response, + } +} + /// The connection requests arrive on, in the form a tonic server records it: /// the TCP ends, and behind TLS the client's certificate chain. /// @@ -338,9 +359,12 @@ impl ProxyService { /// tower service. Only the guards whose scope names `fallback` traffic see /// them; CORS and tracing are the fallback's own. /// - /// A request whose path a route answers but not with its method stays with - /// the proxy (`405`), as does every gRPC request and every browser - /// preflight for a gRPC-Web call, which follows the call it announces. + /// A URL whose path a transcoded template's route matches, but which no + /// binding answers (its field template or custom verb does not fit), is + /// the fallback's too. A request whose path a route answers but not with + /// its method stays with the proxy (`405`), as do every gRPC request and + /// every browser preflight for a gRPC-Web call, which follows the call it + /// announces. #[must_use] pub fn with_fallback(mut self, fallback: F) -> Self where @@ -348,16 +372,22 @@ impl ProxyService { F::Response: IntoResponse, F::Future: Send + 'static, { + let fallback = BoxedService::new(fallback.map_response(IntoResponse::into_response)); + let guarded = if self.guards.cover(Class::Fallback) { + self.guards.service(fallback, Class::Fallback) + } else { + fallback + }; // The routes' own layers do not reach a fallback set after them, so // it gets the client-address resolution of its own, around its guards. + // A URL the routes hand back unanswered passed none of their layers + // either, and takes the same way. let resolve = ClientAddressLayer::with(self.guards.client_address.clone()); - self.routes = if self.guards.cover(Class::Fallback) { - let fallback = BoxedService::new(fallback.map_response(IntoResponse::into_response)); - self.routes - .fallback_service(resolve.layer(self.guards.service(fallback, Class::Fallback))) - } else { - self.routes.fallback_service(resolve.layer(fallback)) - }; + let fallback = BoxedService::new(resolve.layer(guarded)); + self.routes = self + .routes + .fallback_service(fallback.clone()) + .layer(axum::middleware::from_fn_with_state(fallback, redispatch)); self } diff --git a/src/transcode/mod.rs b/src/transcode/mod.rs index c6d2f1f..cdd1617 100644 --- a/src/transcode/mod.rs +++ b/src/transcode/mod.rs @@ -14,27 +14,29 @@ pub mod codec; pub mod error; pub(crate) mod httpbody; pub mod metadata; +pub(crate) mod path; pub mod request; pub(crate) mod response; pub(crate) mod rule; mod select; +mod table; +pub use path::proto_path_to_axum; pub use select::RpcSelection; use axum::body::{Body, Bytes}; -use axum::extract::{OriginalUri, Path, RawQuery, State}; +use axum::extract::{FromRequest, FromRequestParts, OriginalUri, Path, Request, State}; use axum::http::header::{ALLOW, CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{MethodFilter, MethodRouter}; -use axum::Extension; use axum::Router; use futures::{StreamExt, TryStreamExt}; use prost_reflect::{ DescriptorPool, DynamicMessage, FieldDescriptor, MessageDescriptor, MethodDescriptor, SerializeOptions, }; +use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -47,8 +49,10 @@ use crate::received::ReceivedRequest; use crate::service::ConnectionInfo; use crate::upstream::Upstream; use error::{ErrorDetailsPolicy, StatusDetails}; +use path::MountedPath; use response::UpstreamHeaders; use rule::RouteMethod; +use table::{Choice, PathTable, Routes}; /// How long the upstream may take to answer a call with its response headers /// when the client's `grpc-timeout` asks for no shorter deadline. @@ -235,34 +239,180 @@ pub fn routes(pool: &DescriptorPool, aliases: &[AliasConfig]) /// [`routes`], built as `options` describe. /// -/// A second binding for a method and path already taken (or any binding on a -/// path a `custom` `*` rule takes, which answers every method) is skipped with -/// an error in the log. +/// A binding answers the requests whose path matches its template, custom verb +/// included (`/v1/{name}:cancel`, google/api/http.proto `Verb`), and whose +/// method is its own; a GET binding answers HEAD too. A request's verb is its +/// last segment from the first unencoded `:`: a verb some binding of its path +/// binds owns the URL, answered by that verb's bindings alone; a verb none +/// binds stays part of the last variable. A URL that bindings answer, but none +/// with the request's method, is `405` with their methods in `Allow`; a path +/// that matches a template but that no binding answers is `404`. +/// +/// A second binding for a method, path and verb already taken (or any binding +/// on a path and verb a `custom` `*` rule takes, which answers every method), +/// and a template the router cannot match (text around a variable within one +/// segment, a path that does not start with `/`), are skipped with an error in +/// the log. pub fn routes_with_options( pool: &DescriptorPool, aliases: &[AliasConfig], options: &TranscodeOptions, ) -> Router { - let bindings = route_bindings(pool, aliases, &options.selection); - if bindings.is_empty() { + let (router, chooser) = routes_and_chooser(pool, aliases, options); + match chooser { + Some(chooser) => chooser.layer(router), + None => router, + } +} + +/// The routes of [`routes_with_options`] without their [`Chooser`], which +/// the caller lays over them outside its own layers. +pub(crate) fn routes_and_chooser( + pool: &DescriptorPool, + aliases: &[AliasConfig], + options: &TranscodeOptions, +) -> (Router, Option) { + let tables = path_tables(pool, aliases, options); + if tables.is_empty() { tracing::warn!("No HTTP-annotated RPCs found in proto descriptors"); - return Router::new(); + return (Router::new(), None); + } + + let routes = Arc::new(Routes::new(tables)); + let chooser = Chooser { + tables: routes + .tables + .iter() + .enumerate() + .map(|(table, mounted)| (mounted.path.clone(), table)) + .collect(), + routes: routes.clone(), + elsewhere: None, + }; + let mut router: Router = Router::new(); + for (table, mounted) in routes.tables.iter().enumerate() { + let routes = routes.clone(); + // Every method goes to the routes, which answer 405 themselves: the + // URL, not the path alone, decides which methods a path answers. A + // HEAD a GET binding serves still goes out without a body: axum's + // route future empties the body of every HEAD response, `any` routes + // included. + router = router.route( + &mounted.path, + axum::routing::any(move |State(state): State, request: Request| { + dispatch(routes, table, state, request) + }), + ); + } + (router, Some(chooser)) +} + +/// Chooses the binding a request a transcoded route matched reaches, before +/// any layer under it runs. A URL no binding answers (its field template, its +/// custom verb) is no transcoded request: it goes `elsewhere` (the other +/// routes, which hand back what they do not answer either), or comes back +/// [`Unrouted`]. +#[derive(Clone)] +pub(crate) struct Chooser { + routes: Arc, + /// Each table's router path, as the router reports the route it matched. + tables: HashMap, + elsewhere: Option, +} + +impl Chooser { + /// This chooser handing a URL no binding answers to `elsewhere`. + pub(crate) fn with_elsewhere(mut self, elsewhere: crate::guard::BoxedService) -> Self { + self.elsewhere = Some(elsewhere); + self + } + + /// `router`, holding the transcoded routes, with this chooser outside its + /// layers. + pub(crate) fn layer(self, router: Router) -> Router { + router.layer(axum::middleware::from_fn_with_state(self, choose_first)) + } +} + +/// `request`, which no route answers, on a `404` that a fallback set after +/// the routes takes instead. +pub(crate) fn unrouted(request: Request) -> Response { + let mut response = StatusCode::NOT_FOUND.into_response(); + let request = Unrouted(Arc::new(std::sync::Mutex::new(Some(request)))); + response.extensions_mut().insert(request); + response +} + +/// The binding chosen for a request before the layers under the +/// [`Chooser`], for [`dispatch`]. +#[derive(Clone)] +struct Chosen(Choice); + +/// A request no route answers, on the `404` [`unrouted`] makes of it. +#[derive(Clone)] +pub(crate) struct Unrouted(Arc>>); + +impl Unrouted { + /// The request, the first time it is taken. + pub(crate) fn take(&self) -> Option { + self.0.lock().map_or(None, |mut request| request.take()) } +} +async fn choose_first( + State(chooser): State, + mut request: Request, + next: axum::middleware::Next, +) -> Response { + // The route the router matched, when it is a transcoded one. + let matched = request + .extensions() + .get::() + .and_then(|path| chooser.tables.get(path.as_str()).copied()); + let Some(matched) = matched else { + return next.run(request).await; + }; + match chooser + .routes + .choose(matched, request.method(), request.uri().path()) + { + Choice::NotFound => match chooser.elsewhere { + Some(elsewhere) => match tower::ServiceExt::oneshot(elsewhere, request).await { + Ok(response) => response, + Err(never) => match never {}, + }, + None => unrouted(request), + }, + choice => { + request.extensions_mut().insert(Chosen(choice)); + next.run(request).await + } + } +} + +/// The tables [`routes_with_options`] mounts, one per router path, each +/// holding every binding of that path in binding order. +fn path_tables( + pool: &DescriptorPool, + aliases: &[AliasConfig], + options: &TranscodeOptions, +) -> Vec { + let bindings = route_bindings(pool, aliases, &options.selection, true); tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len()); // At most two renderers, without and with the opaque-detail extension, // each shared by every route that uses it and built only when one does. // The second is a copy of the first: the descriptor pool inside is shared. let mut status_details: [Option>; 2] = [None, None]; - // Every binding of one path goes into the same method router, in binding - // order. - let mut paths: Vec = Vec::new(); - let mut path_index: HashMap = HashMap::new(); + // The router tells paths apart by shape only, so every binding of one + // shape goes into one table, whatever its capture names. + let mut tables: Vec = Vec::new(); + let mut by_shape: HashMap = HashMap::new(); for mut binding in bindings { let policy = &options.error_details; - if policy.enabled_for(&binding.axum_path) { - let opaque = policy.opaque_for(&binding.axum_path); + let route = binding.mount.display(); + if policy.enabled_for(&route) { + let opaque = policy.opaque_for(&route); let slot = usize::from(opaque); if status_details[slot].is_none() { let base = status_details @@ -277,163 +427,244 @@ pub fn routes_with_options( } binding.entry.ndjson_envelope = options.ndjson_envelope; binding.entry.denied_headers = options.denied_response_headers.clone(); - let index = match path_index.get(&binding.axum_path) { - Some(&index) => index, + match by_shape.get(&binding.mount.shape) { + Some(&index) => tables[index].add(binding.mount, binding.entry), None => { - path_index.insert(binding.axum_path.clone(), paths.len()); - paths.push(PathRoutes { - path: binding.axum_path, - methods: Vec::new(), - }); - paths.len() - 1 + by_shape.insert(binding.mount.shape.clone(), tables.len()); + tables.push(PathTable::new(binding.mount, binding.entry)); } - }; - paths[index].add(Arc::new(binding.entry)); + } } - let mut router: Router = Router::new(); - for path in &paths { - router = router.route(&path.path, path.method_router()); - } - router + retain_mountable( + &mut tables, + |table| table.path.as_str(), + |path, error| { + tracing::error!( + path = %path, + %error, + "transcoded route conflicts with another; skipping its bindings" + ); + }, + ); + tables } -/// The axum handler serving the route entry `$entry`, for the state type `S` -/// in scope. A macro because the closure's handler type cannot be named. -macro_rules! endpoint { - ($entry:expr) => {{ - let entry: Arc = $entry; - move |state: State, - method: Method, - OriginalUri(uri): OriginalUri, - headers: HeaderMap, - path_params: Path, - raw_query: RawQuery, - connection: Option>, - address: Option>, - body: Bytes| { - let client = Client { - headers, - origin: Origin { - connection: connection.map(|Extension(connection)| connection), - address: address.map(|Extension(address)| address), - }, - line: RequestLine { method, uri }, - }; - handle(state, client, path_params, raw_query, body, entry) +/// Keep the items whose router path a router holding the ones kept before +/// accepts, in order; `refused` hears of each left out. axum would panic on +/// such a path, and matchit is the router axum matches with. +fn retain_mountable( + items: &mut Vec, + path: impl Fn(&T) -> &str, + mut refused: impl FnMut(&str, matchit::InsertError), +) { + let mut router = matchit::Router::new(); + items.retain(|item| match router.insert(path(item), ()) { + Ok(()) => true, + Err(error) => { + refused(path(item), error); + false } - }}; + }); } -/// The bindings mounted at one axum path. -struct PathRoutes { - path: String, - methods: Vec>, +/// The shapes of the router paths [`routes_with_options`] mounts for +/// `selection`: what the OpenAPI document may promise. +pub(crate) fn mounted_shapes( + pool: &DescriptorPool, + aliases: &[AliasConfig], + selection: &RpcSelection, +) -> std::collections::HashSet { + // One path per shape, the first binding's, as the tables are built. + let mut seen = std::collections::HashSet::new(); + let mut paths: Vec = route_bindings(pool, aliases, selection, false) + .into_iter() + .filter_map(|binding| { + seen.insert(binding.mount.shape.clone()) + .then_some(binding.mount) + }) + .collect(); + retain_mountable(&mut paths, |mount| mount.axum.as_str(), |_, _| {}); + paths.into_iter().map(|mount| mount.shape).collect() } -impl PathRoutes { - /// Add `entry` unless its method is already answered on this path. - fn add(&mut self, entry: Arc) { - let taken = self.methods.iter().any(|existing| { - existing.http_method == entry.http_method - || existing.http_method == RouteMethod::Any - || entry.http_method == RouteMethod::Any - }); - if taken { - tracing::error!( - method = entry.http_method.as_str(), - path = %self.path, - rpc = %entry.grpc_path, - "HTTP method and path already bound to another RPC; skipping this binding" - ); - return; +/// Serve `request`, which the router matched to the table at `matched`: +/// choose the binding before anything is extracted, so a request no binding +/// answers gets its 404 or 405 without its body being read. +async fn dispatch( + routes: Arc, + matched: usize, + state: S, + request: Request, +) -> Response { + let (mut parts, body) = request.into_parts(); + let choice = match parts.extensions.remove::() { + Some(Chosen(choice)) => choice, + None => routes.choose(matched, &parts.method, parts.uri.path()), + }; + let (table, index) = match choice { + Choice::Route { table, index } => (table, index), + Choice::MethodNotAllowed(allow) => { + return (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response() + } + Choice::NotFound => return unrouted(Request::from_parts(parts, body)), + }; + let mut path_params = match Path::::from_request_parts(&mut parts, &state).await { + Ok(Path(params)) => params, + Err(rejection) => return rejection.into_response(), + }; + let multi_segment = routes.tables[table].multi_segment(index); + if table != matched || !multi_segment.is_empty() { + // Another path the request matches answers it (a verb bound there, or + // the only bindings without a verb), or a variable over several + // segments keeps `%2F` the router decoded: the captures come from the + // request's path, the router's prefix parameters stay. + let path = parts.uri.path(); + if let Err(rejection) = rebind( + &routes, + matched, + table, + multi_segment, + path, + &mut path_params, + ) { + return rejection.into_response(routes.tables[table].entry(index)); } - self.methods.push(entry); } + routes.tables[table].bind_params(index, &mut path_params); + // The target as received, before a router the proxy is nested in strips + // its prefix; the router records it on every request it routes. + let uri = match parts.extensions.remove::() { + Some(OriginalUri(uri)) => uri, + None => parts.uri.clone(), + }; + // Taken rather than copied: reading the body needs only its limit, which + // stays in the extensions. + let client = Client { + headers: std::mem::take(&mut parts.headers), + origin: Origin { + connection: parts.extensions.remove::(), + address: parts.extensions.remove::(), + }, + line: RequestLine { + method: std::mem::replace(&mut parts.method, Method::GET), + uri, + }, + }; + let body = match Bytes::from_request(Request::from_parts(parts, body), &state).await { + Ok(body) => body, + Err(rejection) => return rejection.into_response(), + }; + let entry = RouteRef { + routes, + table, + index, + }; + handle(state, client, path_params, body, entry).await +} - /// One method router for every binding of the path. Methods axum routes by - /// itself are registered directly; any other token (a `custom` rule such - /// as `PROPFIND`) is dispatched by a fallback that answers `405` with the - /// full `Allow` list (RFC 9110 §15.5.6) for a method nobody binds. - fn method_router(&self) -> MethodRouter { - let mut router = MethodRouter::new(); - let mut extension: Vec<(Method, Arc)> = Vec::new(); - let mut allow: Vec<&str> = Vec::new(); - for entry in &self.methods { - match &entry.http_method { - // `add` keeps a `*` binding alone on its path. - RouteMethod::Any => return axum::routing::any(endpoint!(entry.clone())), - RouteMethod::One(method) => { - allow.push(method.as_str()); - match MethodFilter::try_from(method.clone()) { - Ok(filter) => router = router.on(filter, endpoint!(entry.clone())), - Err(_) => extension.push((method.clone(), entry.clone())), - } - } - } - } - if extension.is_empty() { - return router; - } - // A GET route answers HEAD too. - if allow.contains(&"GET") && !allow.contains(&"HEAD") { - allow.push("HEAD"); - } - let allow = HeaderValue::from_str(&allow.join(", ")) - .expect("method tokens are valid header value characters"); - let extension: Arc<[(Method, Arc)]> = extension.into(); - // The method decides before anything is extracted, so a request no - // binding answers gets its 405 without its body being read. - router.fallback( - move |State(state): State, request: axum::extract::Request| async move { - match extension - .iter() - .find(|(bound, _)| bound == request.method()) - { - Some((_, entry)) => { - axum::handler::Handler::call(endpoint!(entry.clone()), request, state).await - } - None => (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response(), - } - }, - ) +/// Replace the captures of the table at `matched` in `params` with those of +/// the table at `chosen`, percent-decoded as the router decodes its own except +/// at the positions of `multi_segment`, which keep `%2F`. +fn rebind( + routes: &Routes, + matched: usize, + chosen: usize, + multi_segment: &[usize], + path: &str, + params: &mut PathParams, +) -> Result<(), Unmappable> { + for name in &routes.tables[matched].captures { + params.remove(name); + } + let found = routes + .params_of(chosen, path) + .expect("the chosen table was found by matching this path"); + for (position, (name, raw)) in found.iter().enumerate() { + let decoded: Cow<'_, [u8]> = if multi_segment.contains(&position) { + path::decode_multi_segment(raw) + } else { + percent_encoding::percent_decode_str(raw).into() + }; + let value = String::from_utf8(decoded.into_owned()) + .map_err(|_| Unmappable(format!("path parameter {name} is not valid UTF-8")))?; + params.insert(name.to_owned(), value); } + Ok(()) } -/// One transcode route to mount: the RPC entry that serves it and the axum path -/// to register it at. +/// The binding serving a request, held through the routes: they are already +/// shared with the request, so naming the binding takes no refcount of its +/// own. +#[derive(Clone)] +struct RouteRef { + routes: Arc, + table: usize, + index: usize, +} + +impl std::ops::Deref for RouteRef { + type Target = RouteEntry; + + fn deref(&self) -> &RouteEntry { + self.routes.tables[self.table].entry(self.index) + } +} + +/// One transcode route to mount: the RPC entry that serves it and where it is +/// mounted. struct RouteBinding { entry: RouteEntry, - axum_path: String, + mount: MountedPath, +} + +impl RouteBinding { + fn claim(&self) -> table::Claim<'_> { + table::Claim { + method: &self.entry.http_method, + verb: self.mount.verb.as_deref(), + template: self.mount.last_template.as_deref(), + literals: &self.mount.literals, + } + } } /// The single source of truth for what [`routes`] mounts: every binding of -/// every unary and server-streaming RPC, plus its config aliases. Both -/// [`routes`] (to build handlers) and [`route_paths`] (to enumerate paths for -/// collision checks) consume this, so the mounted set and the enumerated set -/// cannot drift apart. +/// every unary and server-streaming RPC, plus its config aliases, less the +/// templates the router cannot match. Both [`routes`] (to build handlers) and +/// [`route_paths`] (to enumerate paths for collision checks) consume this, so +/// the mounted set and the enumerated set cannot drift apart. A template left +/// out is logged when `report` is set. fn route_bindings( pool: &DescriptorPool, aliases: &[AliasConfig], selection: &RpcSelection, + report: bool, ) -> Vec { let mut bindings = Vec::new(); + let mut push = |mount: MountedPath, entry: RouteEntry| match mount.routable() { + Ok(()) => bindings.push(RouteBinding { entry, mount }), + Err(_) if !report => {} + Err(error) => tracing::error!( + path = %mount.display(), + rpc = %entry.grpc_path, + %error, + "google.api.http path template cannot be routed; skipping this binding" + ), + }; for entry in extract_routes(pool, selection) { for alias in aliases { if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) { if alias.from.ends_with("/{path}") { let prefix = alias.from.trim_end_matches("/{path}"); - bindings.push(RouteBinding { - axum_path: proto_path_to_axum(&format!("{prefix}{suffix}")), - entry: entry.clone(), - }); + push( + MountedPath::new(&format!("{prefix}{suffix}")), + entry.clone(), + ); } } } - bindings.push(RouteBinding { - axum_path: proto_path_to_axum(&entry.http_path), - entry, - }); + push(MountedPath::new(&entry.http_path), entry); } bindings } @@ -447,17 +678,98 @@ fn route_bindings( /// (e.g. a forward-auth endpoint). /// /// Each entry is `(method, path)` where `method` is the uppercase HTTP token, -/// or `*` for a `custom` rule that answers every method, so callers can -/// distinguish same-path/different-method routes from real conflicts. +/// or `*` for a route that answers every method, so callers can distinguish +/// same-path/different-method routes from real conflicts. The bindings of one +/// path are listed as the route they share: `*` once when a `custom` `*` rule +/// or a custom verb after a variable is among them (the transcoded routes +/// match the verb, and answer a bound verb's other methods with 405), else +/// each method once. Two bindings that cannot both serve (one method or a `*` +/// rule, one verb) are listed both, so the caller sees the collision. pub fn route_paths( pool: &DescriptorPool, aliases: &[AliasConfig], selection: &RpcSelection, ) -> Vec<(String, String)> { - route_bindings(pool, aliases, selection) - .into_iter() - .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path)) - .collect() + // The bindings of each route shape, in first-binding order. + let mut shapes: Vec> = Vec::new(); + let mut by_shape: HashMap = HashMap::new(); + // The routes themselves report a template left out. + for binding in route_bindings(pool, aliases, selection, false) { + match by_shape.get(&binding.mount.shape) { + Some(&index) => shapes[index].push(binding), + None => { + by_shape.insert(binding.mount.shape.clone(), shapes.len()); + shapes.push(vec![binding]); + } + } + } + let mut paths = Vec::new(); + for bindings in &shapes { + let listed = |method: &str, path: &str| (method.to_owned(), path.to_owned()); + // A path with a verb after its variable answers every method: one + // whose verb is bound only for others is told so with 405. + match bindings + .iter() + .find(|b| b.entry.http_method == RouteMethod::Any || b.mount.verb.is_some()) + { + Some(star) => paths.push(listed("*", &star.mount.axum)), + None => { + let mut methods: Vec<&str> = Vec::new(); + for binding in bindings { + let method = binding.entry.http_method.as_str(); + if !methods.contains(&method) { + methods.push(method); + paths.push(listed(method, &binding.mount.axum)); + } + } + } + } + for (at, binding) in bindings.iter().enumerate() { + let clashes = bindings[..at] + .iter() + .any(|earlier| table::clash(&earlier.claim(), &binding.claim())); + if clashes { + paths.push(listed( + binding.entry.http_method.as_str(), + &binding.mount.axum, + )); + } + } + } + paths +} + +/// The methods the transcoded bindings for one pool, aliases and selection +/// answer: what [`route_paths`] lists under `*` still names methods a route +/// answers, or every one for a `custom` `*` rule. +pub(crate) struct BoundMethods { + /// The methods the bindings name, each once. + pub(crate) methods: Vec, + /// Whether a `custom` `*` rule answers every method. + pub(crate) every: bool, +} + +/// The [`BoundMethods`] of the transcoded bindings for this pool, aliases +/// and selection. +pub(crate) fn bound_methods( + pool: &DescriptorPool, + aliases: &[AliasConfig], + selection: &RpcSelection, +) -> BoundMethods { + let mut bound = BoundMethods { + methods: Vec::new(), + every: false, + }; + for binding in route_bindings(pool, aliases, selection, false) { + match binding.entry.http_method { + RouteMethod::One(method) if !bound.methods.contains(&method) => { + bound.methods.push(method); + } + RouteMethod::One(_) => {} + RouteMethod::Any => bound.every = true, + } + } + bound } /// JSON serialization options shared by the unary and streaming response paths, @@ -603,12 +915,11 @@ struct Origin { /// Serve one request on a transcoded route. async fn handle( - State(proxy_state): State, + proxy_state: S, client: Client, - Path(path_params): Path, - RawQuery(raw_query): RawQuery, + path_params: PathParams, body: Bytes, - entry: Arc, + entry: RouteRef, ) -> Response { let keep_alive_secs = proxy_state.sse_keep_alive_secs(); let Client { @@ -616,18 +927,20 @@ async fn handle( origin, line, } = client; - let prepared = line.received(&entry).and_then(|received| { - let mut call = prepare( - proxy_state, - &headers, - origin, - &path_params, - raw_query.as_deref(), - body, - &entry, - )?; + // The query is read where it was received, before the request line is + // handed on. + let prepared = prepare( + proxy_state, + &headers, + origin, + &path_params, + line.uri.query(), + body, + &entry, + ) + .and_then(|mut call| { // Next to the extensions `prepare` sets, for an upstream in process. - call.request.extensions_mut().insert(received); + call.request.extensions_mut().insert(line.received(&entry)?); Ok(call) }); let call = match prepared { @@ -905,7 +1218,7 @@ fn upstream_error(mut status: tonic::Status, entry: &RouteEntry) -> Response { /// sent and are not forwarded. async fn streaming_call( call: Call, - entry: Arc, + entry: RouteRef, use_sse: bool, keep_alive_secs: u64, ) -> Response { @@ -946,7 +1259,7 @@ async fn streaming_call( /// and the client sees a truncated transfer instead of a clean end. async fn http_body_stream( mut stream: tonic::Streaming, - entry: Arc, + entry: RouteRef, initial: MetadataMap, ) -> Response { let first = match stream.message().await { @@ -1271,118 +1584,5 @@ fn response_shape(output: &MessageDescriptor, response_body: Option) -> } } -/// Convert a `google.api.http` path template to axum 0.8 path syntax. -/// -/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain -/// single-segment params pass through verbatim. Only field-path templates and -/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}` -/// and rejects any segment starting with `:`): -/// - `{name=*}` (single segment) -> `{name}` -/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all) -/// - bare `*` segment -> `{wildcardN}` -/// - bare `**` segment -> `{*wildcardN}` (axum catch-all) -pub fn proto_path_to_axum(path: &str) -> String { - let mut out = String::with_capacity(path.len()); - - let segments = split_top_level(path); - let last = segments.len().saturating_sub(1); - for (idx, segment) in segments.iter().enumerate() { - if idx > 0 { - out.push('/'); - } - out.push_str(&convert_segment(segment, idx, idx == last)); - } - - out -} - -/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span. -/// -/// google.api.http field templates can embed slashes inside a single capture -/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive -/// `str::split('/')` would fracture the brace span into invalid fragments. -/// Tracking brace depth keeps each capture intact. -fn split_top_level(path: &str) -> Vec<&str> { - let mut segments = Vec::new(); - let mut depth = 0usize; - let mut start = 0usize; - - for (i, ch) in path.char_indices() { - match ch { - '{' => depth += 1, - // Decrement only on a matched brace; a stray `}` (malformed input) - // is treated as a literal rather than driving depth negative. - '}' if depth > 0 => depth -= 1, - '/' if depth == 0 => { - segments.push(&path[start..i]); - start = i + 1; - } - _ => {} - } - } - segments.push(&path[start..]); - segments -} - -/// Convert a single top-level path segment from proto template to axum 0.8 form. -/// -/// `is_last` indicates the terminal segment: axum permits a catch-all capture -/// (`{*name}`) only there, so catch-alls in any other position must degrade. -fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String { - if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { - // Brace capture, possibly with a `name=template` field path. - if let Some((name, template)) = inner.split_once('=') { - return match template { - // Single-segment field path collapses to a plain capture. - "*" => format!("{{{name}}}"), - // Multi-segment catch-all maps to axum's `{*name}` (terminal only). - "**" => catch_all(name, is_last), - // Templates with interspersed literals (`{name=shelves/*/books/*}`) - // have no faithful axum form: axum cannot bind literal segments - // into one capture. Collapse to a catch-all so routing stays - // deterministic and the field still binds to the matched tail, - // and warn so the limitation surfaces instead of mis-routing. - _ => { - tracing::warn!( - template = %inner, - "google.api.http multi-segment field template is not fully \ - supported; routing it as a catch-all capture" - ); - catch_all(name, is_last) - } - }; - } - // Plain `{name}` is already valid axum 0.8 syntax. - return format!("{{{inner}}}"); - } - - // Bare wildcards: name them by position so multiple wildcards never collide. - match segment { - "**" => catch_all(&format!("wildcard{idx}"), is_last), - "*" => format!("{{wildcard{idx}}}"), - literal => literal.to_string(), - } -} - -/// Emit an axum catch-all `{*name}` when `is_last`, else degrade to a -/// single-segment `{name}` capture. -/// -/// axum accepts a catch-all only in the final path segment; a mid-path -/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes -/// from a malformed or unsupported google.api.http template, so we degrade -/// (capturing one segment) and warn rather than panic the whole router. -fn catch_all(name: &str, is_last: bool) -> String { - if is_last { - format!("{{*{name}}}") - } else { - tracing::warn!( - capture = %name, - "catch-all in a non-terminal path segment is unrepresentable in axum; \ - degrading to a single-segment capture" - ); - format!("{{{name}}}") - } -} - #[cfg(test)] mod tests; diff --git a/src/transcode/path.rs b/src/transcode/path.rs new file mode 100644 index 0000000..ad7d8c7 --- /dev/null +++ b/src/transcode/path.rs @@ -0,0 +1,479 @@ +//! `google.api.http` path templates in the form the router mounts them. +//! +//! The template grammar (google/api/http.proto) is +//! `Template = "/" Segments [ Verb ]` with `Verb = ":" LITERAL`. axum (matchit) +//! matches a literal after a variable in no segment, so a verb that follows a +//! variable or a wildcard is split off here and matched by the transcoded +//! router itself; a verb after a literal stays part of that literal. + +use std::borrow::Cow; + +/// Where one binding is mounted. +#[derive(Debug, Clone)] +pub(crate) struct MountedPath { + /// The axum path (`/v1/{name}`), without a verb that follows a variable. + pub(crate) axum: String, + /// `axum` with every capture unnamed: the bindings that share it share one + /// route, since the router tells their paths apart by shape only. + pub(crate) shape: String, + /// The capture names of `axum`, in path order. + pub(crate) captures: Vec, + /// For each capture, whether it stands for a bare `*` or `**`: the router + /// needs a name for it, but it binds no field (google/api/http.proto). + pub(crate) unbound: Vec, + /// For each capture, whether it is (part of) a variable matching several + /// segments, whose value keeps `%2F` encoded (google/api/http.proto). + pub(crate) multi_segment: Vec, + /// The custom verb after the last segment's variable, `:` included. + pub(crate) verb: Option, + /// Whether the last capture may be empty: a `**` matches zero or more + /// segments (google/api/http.proto), any other variable at least one. + pub(crate) empty_last: bool, + /// The field template of the last capture when the router cannot hold it + /// (`{name=shelves/*/books/*}` mounted as a catch-all): its segments, which + /// the matched value must follow. + pub(crate) last_template: Option>, + /// The index of the last segment, the one the last capture starts at. + pub(crate) last_segment: usize, + /// Why the router cannot hold this template faithfully, when it cannot. + pub(crate) unsupported: Option, + /// The fields bound to a multi-segment template before the last segment, + /// spelled out in the path: each is put back together from its parts. + pub(crate) composites: Vec, + /// The literals of those templates that hold a percent-escape, as + /// written, with the index of their path segment. The router compares a + /// literal byte for byte, while `%3a` and `%3A` are one octet (RFC 3986 + /// §6.2.2.1), so such a segment is mounted as a capture and compared by + /// the transcoded routes. + pub(crate) literals: Vec<(usize, String)>, +} + +/// A field whose template (`{parent=publishers/*}`) the path spells out +/// segment by segment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Composite { + pub(crate) field: String, + pub(crate) parts: Vec, +} + +/// One segment of a [`Composite`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Part { + /// A literal of the template without a percent-escape. + Literal(String), + /// A `*` of the template, or a literal with a percent-escape: the capture + /// at this position of the path. + Capture(usize), +} + +impl MountedPath { + /// Mount `template`, a `google.api.http` path template. + pub(crate) fn new(template: &str) -> Self { + let (base, verb) = split_verb(template); + let segments = split_top_level(base); + let last = segments.len() - 1; + let mut axum = String::with_capacity(base.len()); + let mut shape = String::with_capacity(base.len()); + let mut captures = Vec::new(); + let mut unbound = Vec::new(); + let mut multi_segment = Vec::new(); + let mut composites = Vec::new(); + let mut literals = Vec::new(); + let mut unsupported = None; + // Segments written so far: the last capture starts at this index. + let mut written = 0; + let empty_last = is_double_wildcard(segments[last]); + let last_template = field_template(segments[last]) + .filter(|template| *template != "*" && *template != "**") + .map(|template| { + split_top_level(template) + .into_iter() + .map(str::to_owned) + .collect() + }); + let mut last_segment = 0; + for (idx, segment) in segments.iter().enumerate() { + if idx == last { + last_segment = written; + } + let mut push_segment = |axum: &mut String, shape: &mut String| { + if written > 0 { + axum.push('/'); + shape.push('/'); + } + written += 1; + }; + // A multi-segment template before the last segment: the router + // holds no capture across segments there, so its segments are + // written out; `**` among them has no such form. + if field_template(segment).is_some_and(|template| template.contains(['{', '}'])) { + unsupported = Some(format!( + "`{segment}` nests a variable in a field template, which holds none" + )); + } + // `**` must be the last part of the path (google/api/http.proto); + // anywhere else it would also make matching try every split. + if idx == last + && field_template(segment) + .is_some_and(|template| template.split('/').rev().skip(1).any(|p| p == "**")) + { + unsupported = Some(format!( + "`{segment}` puts `**` before another segment; it must be the last one" + )); + } + let inner = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')); + if let Some((field, template)) = inner + .and_then(|inner| inner.split_once('=')) + .filter(|(_, template)| idx != last && *template != "*") + { + if template.split('/').any(|part| part == "**") { + unsupported = Some(format!( + "`**` in `{segment}` before the last segment matches any number of \ + segments, which the router cannot hold there" + )); + } else { + // A template of one segment (`{name=foo}`) is a variable of + // one segment, decoded in full. + let multi = template.contains('/'); + let mut parts = Vec::new(); + for (k, part) in template.split('/').enumerate() { + push_segment(&mut axum, &mut shape); + // An escaped literal is a capture to the router, so its + // case does not matter (RFC 3986 §6.2.2.1); like any + // capture, it cannot share its position with a + // catch-all, which the router then refuses. + let escaped = part.contains('%'); + if part == "*" || escaped { + let name = format!("{field}.{k}"); + axum.push('{'); + axum.push_str(&name); + axum.push('}'); + shape.push_str("{}"); + captures.push(name); + unbound.push(true); + multi_segment.push(multi); + } else { + axum.push_str(part); + shape.push_str(part); + } + if escaped { + // The index of this segment: one per `/` before it. + let at = axum.bytes().filter(|&b| b == b'/').count(); + literals.push((at, part.to_owned())); + } + // An escaped literal is taken from the request, decoded + // as the variable is: a kept `%2F` keeps its case there. + parts.push(if part == "*" || escaped { + Part::Capture(captures.len() - 1) + } else { + Part::Literal(part.to_owned()) + }); + } + composites.push(Composite { + field: field.to_owned(), + parts, + }); + continue; + } + } else if idx != last && is_double_wildcard(segment) { + unsupported = Some(format!( + "`{segment}` before the last segment matches any number of segments, \ + which the router cannot hold there" + )); + } + push_segment(&mut axum, &mut shape); + match convert_segment(segment, idx, idx == last) { + Segment::Literal(literal) => { + axum.push_str(literal); + shape.push_str(literal); + } + Segment::Capture { name, catch_all } => { + let open = if catch_all { "{*" } else { "{" }; + axum.push_str(open); + axum.push_str(&name); + axum.push('}'); + shape.push_str(open); + shape.push('}'); + captures.push(name); + unbound.push(*segment == "*" || *segment == "**"); + // A catch-all may hold a template of one segment; what + // counts is the template. + multi_segment.push(catch_all && spans_segments(segment)); + } + } + } + Self { + axum, + shape, + captures, + unbound, + multi_segment, + verb: verb.map(str::to_owned), + empty_last, + last_template, + last_segment, + unsupported, + composites, + literals, + } + } + + /// Whether the router can serve this template as written: a template it + /// cannot hold faithfully, or a path axum would refuse, is not served. + pub(crate) fn routable(&self) -> Result<(), String> { + match &self.unsupported { + Some(reason) => Err(reason.clone()), + None => mountable(&self.axum), + } + } + + /// The route as written, verb included: what route policies match and + /// what the log names. + pub(crate) fn display(&self) -> Cow<'_, str> { + match &self.verb { + None => Cow::Borrowed(&self.axum), + Some(verb) => Cow::Owned(format!("{}{verb}", self.axum)), + } + } +} + +/// Convert a `google.api.http` path template to axum 0.8 path syntax. +/// +/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain +/// single-segment params pass through verbatim. Only field-path templates and +/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}` +/// and rejects any segment starting with `:`): +/// - `{name=*}` (single segment) -> `{name}` +/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all) +/// - bare `*` segment -> `{N}`, N its segment index +/// - bare `**` segment -> `{*N}` (axum catch-all) +/// +/// A custom verb after a variable or wildcard (`{name}:cancel`) is not part of +/// the result: axum cannot match it, so the transcoded router checks it +/// itself. A verb after a literal (`/v1/nodes:batch`) stays in that literal. +pub fn proto_path_to_axum(path: &str) -> String { + MountedPath::new(path).axum +} + +/// Whether axum can register `axum_path` on its own: what `Router::route` +/// checks before it inserts, then the insertion into an empty matchit router, +/// the router axum matches with. A path that fails would panic at +/// `Router::route`. Conflicts between paths are a separate check. +pub(crate) fn mountable(axum_path: &str) -> Result<(), String> { + if !axum_path.starts_with('/') { + return Err("a path template must start with '/'".to_string()); + } + if axum_path + .split('/') + .any(|segment| segment.starts_with(':') || segment.starts_with('*')) + { + return Err("a path segment must not start with ':' or '*'".to_string()); + } + // A variable is a whole segment (google/api/http.proto `Segment`), checked + // here rather than left to the router, which may accept text around one. + let mut captures = 0usize; + for segment in axum_path.split('/') { + let Some(at) = segment.find(['{', '}']) else { + continue; + }; + let inner = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')); + if at != 0 || inner.is_none_or(|inner| inner.contains(['{', '}'])) { + return Err(format!("`{segment}` is not a whole-segment variable")); + } + if !segment.starts_with("{*") { + captures += 1; + } + } + // The router renames captures `a` to `z` and panics on a 26th (catch-alls + // are not renamed). + if captures > 25 { + return Err(format!( + "{captures} variables; the router holds at most 25 before the last" + )); + } + matchit::Router::new() + .insert(axum_path, ()) + .map_err(|e| e.to_string()) +} + +/// Split the custom verb off `template` when its last segment is a variable +/// or a wildcard: `/v1/{name}:cancel` is `/v1/{name}` and `:cancel`. An empty +/// verb is not one (`Verb = ":" LITERAL`), and anything else after a variable +/// stays in the template for the router to reject. +fn split_verb(template: &str) -> (&str, Option<&str>) { + let segments = split_top_level(template); + let last = segments[segments.len() - 1]; + let start = template.len() - last.len(); + let colon = if last.starts_with('{') { + closing_brace(last).map(|close| close + 1) + } else if last.starts_with("**") { + Some(2) + } else if last.starts_with('*') { + Some(1) + } else { + None + }; + match colon { + Some(colon) if last[colon..].starts_with(':') && last.len() > colon + 1 => { + let at = start + colon; + (&template[..at], Some(&template[at..])) + } + _ => (template, None), + } +} + +/// `raw` percent-decoded as the value of a variable matching several segments +/// is: every escape but `%2F` and `%2f`, which stay as written +/// (google/api/http.proto). Borrowed when there is nothing to decode. +pub(crate) fn decode_multi_segment(raw: &str) -> Cow<'_, [u8]> { + let is_slash = + |escape: &[u8]| escape[0] == b'%' && escape[1] == b'2' && escape[2] | 0x20 == b'f'; + let bytes = raw.as_bytes(); + if !bytes.windows(3).any(is_slash) { + return percent_encoding::percent_decode(bytes).into(); + } + let mut decoded = Vec::with_capacity(bytes.len()); + let mut rest = bytes; + while let Some(at) = rest.windows(3).position(is_slash) { + decoded.extend(percent_encoding::percent_decode(&rest[..at])); + decoded.extend_from_slice(&rest[at..at + 3]); + rest = &rest[at + 3..]; + } + decoded.extend(percent_encoding::percent_decode(rest)); + Cow::Owned(decoded) +} + +/// Whether `segment` is a variable over several segments: `**`, or a field +/// template with more than one segment or a `**`. +fn spans_segments(segment: &str) -> bool { + segment == "**" + || field_template(segment) + .is_some_and(|template| template.contains('/') || template == "**") +} + +/// Whether `segment` is `**` or a variable bound to it (`{name=**}`). +fn is_double_wildcard(segment: &str) -> bool { + segment == "**" || field_template(segment) == Some("**") +} + +/// The template a variable segment binds its field to: `shelves/*` of +/// `{name=shelves/*}`, none for a plain `{name}` or a literal. +fn field_template(segment: &str) -> Option<&str> { + segment + .strip_prefix('{') + .and_then(|s| s.strip_suffix('}')) + .and_then(|inner| inner.split_once('=')) + .map(|(_, template)| template) +} + +/// The index of the `}` closing the `{` that opens `segment`. +fn closing_brace(segment: &str) -> Option { + let mut depth = 0usize; + for (i, ch) in segment.char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span. +/// +/// google.api.http field templates can embed slashes inside a single capture +/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive +/// `str::split('/')` would fracture the brace span into invalid fragments. +/// Tracking brace depth keeps each capture intact. Never empty. +fn split_top_level(path: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + + for (i, ch) in path.char_indices() { + match ch { + '{' => depth += 1, + // Decrement only on a matched brace; a stray `}` (malformed input) + // is treated as a literal rather than driving depth negative. + '}' if depth > 0 => depth -= 1, + '/' if depth == 0 => { + segments.push(&path[start..i]); + start = i + 1; + } + _ => {} + } + } + segments.push(&path[start..]); + segments +} + +/// One top-level path segment in axum form. +enum Segment<'a> { + Literal(&'a str), + Capture { name: String, catch_all: bool }, +} + +/// Convert a single top-level path segment from proto template to axum 0.8 form. +/// +/// `is_last` indicates the terminal segment: axum permits a catch-all capture +/// (`{*name}`) only there, so catch-alls in any other position must degrade. +fn convert_segment(segment: &str, idx: usize, is_last: bool) -> Segment<'_> { + if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { + // Brace capture, possibly with a `name=template` field path. + if let Some((name, template)) = inner.split_once('=') { + return match template { + // Single-segment field path collapses to a plain capture. + "*" => capture(name, false), + // Multi-segment catch-all maps to axum's `{*name}` (terminal only). + "**" => catch_all(name, is_last), + // Templates with interspersed literals (`{name=shelves/*/books/*}`) + // have no axum form: axum cannot bind literal segments into one + // capture. The last segment is mounted as a catch-all and the + // transcoded routes check the value against the template; in + // any other position it degrades to one segment, which the + // template cannot match, so that is warned about. + _ => catch_all(name, is_last), + }; + } + // Plain `{name}` is already valid axum 0.8 syntax. + return capture(inner, false); + } + + // Bare wildcards: named by position, so they never collide with each + // other, and starting with a digit, so never with a field path (whose + // names are identifiers) or a spelled-out template's `{field}.{k}`. + match segment { + "**" => catch_all(&idx.to_string(), is_last), + "*" => capture(&idx.to_string(), false), + literal => Segment::Literal(literal), + } +} + +fn capture(name: &str, catch_all: bool) -> Segment<'static> { + Segment::Capture { + name: name.to_owned(), + catch_all, + } +} + +/// A catch-all capture when `is_last`, else a single-segment one. +/// +/// axum accepts a catch-all only in the final path segment; a mid-path +/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes +/// from a malformed or unsupported google.api.http template, so we degrade +/// (capturing one segment) and warn rather than panic the whole router. +fn catch_all(name: &str, is_last: bool) -> Segment<'static> { + if !is_last { + tracing::warn!( + capture = %name, + "catch-all in a non-terminal path segment is unrepresentable in axum; \ + degrading to a single-segment capture" + ); + } + capture(name, is_last) +} diff --git a/src/transcode/table.rs b/src/transcode/table.rs new file mode 100644 index 0000000..a827e6e --- /dev/null +++ b/src/transcode/table.rs @@ -0,0 +1,674 @@ +//! The transcoded routes: the bindings mounted at each router path, and the +//! choice among them for a request, by the custom verb its path ends in and +//! by its method. +//! +//! A request's verb is its last segment from the first unencoded `:`. Neither +//! side of the verb holds one: a client percent-encodes every reserved +//! character of a variable's value, and a `LITERAL` writes its reserved +//! characters percent-encoded too (google/api/http.proto). A verb some binding +//! of the request's path binds owns that URL; a verb none binds stays part of +//! the last variable, as Envoy's transcoder and grpc-gateway leave an unbound +//! verb. + +use std::borrow::Cow; +use std::collections::HashMap; + +use axum::http::{HeaderValue, Method}; + +use super::path::{decode_multi_segment, Composite, MountedPath, Part}; +use super::rule::RouteMethod; +use super::{PathParams, RouteEntry}; + +/// Every transcoded route, and where each custom verb is bound. +pub(super) struct Routes { + pub(super) tables: Vec, + /// For each verb, the tables binding it. The router matches a request to + /// one path whatever its verb, while the verb may be bound on a path of + /// another shape the request also matches. + verbs: HashMap, + /// The tables with a binding without a verb: a path whose bindings all + /// carry verbs must not hide one of these from a request that names no + /// bound verb. + plain: Index, + /// Each table's path alone: whether a request matches a given table, and + /// with which captures, whatever ranks above it. + single: Vec>, +} + +/// Some of the tables, ranked as the router ranks their paths. +#[derive(Default)] +struct Index { + router: matchit::Router, + tables: Vec, +} + +impl Index { + fn insert(&mut self, path: &str, table: usize) { + let inserted = self.router.insert(path, table); + debug_assert!(inserted.is_ok(), "{path}: {inserted:?}"); + self.tables.push(table); + } +} + +/// Every binding the router serves at one path. +pub(super) struct PathTable { + /// The router path, written with the first binding's capture names. + pub(super) path: String, + /// The capture names of `path`, in path order. + pub(super) captures: Vec, + bindings: Vec, +} + +/// One binding of a [`PathTable`]. +struct Binding { + entry: RouteEntry, + /// This binding's names for the captures of the table's path, when they + /// differ from the path's own. + names: Option>, + verb: Option, + template: Option