From 31bcee9622b8fff44e9dae53e87cc5e9ce81ac04 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 12:29:17 +0300 Subject: [PATCH 01/32] fix(dispatch): acquire GIL before AppState write lock to prevent deadlock ensure_compiled_snapshot, setup_database, and close_database took the AppState write lock and then called rebuild_snapshot(), which acquires the GIL to clone Py handles. When those ran on a tokio worker thread without the GIL already held, this created an AB-BA lock-order inversion against handle_rsgi (GIL held, then state.read()/write()): one thread could hold the Rust lock waiting for the GIL while another held the GIL waiting for the Rust lock, deadlocking the worker. Acquire the GIL first in all three call sites, then take the write lock inside that scope, matching the GIL-then-lock order used everywhere else in the hot path. Closes #205 --- src/dispatch.rs | 17 +++++++++++------ src/lib.rs | 15 ++++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/dispatch.rs b/src/dispatch.rs index f5915e3..9176787 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -314,12 +314,17 @@ fn ensure_compiled_snapshot(state: &Arc>) -> Arc` handles, and taking a Rust lock first would let another thread hold the + // GIL while blocked on this same lock — classic AB-BA deadlock (issue #205). + Python::with_gil(|_py| { + let mut st = state.write(); + if st.compiled.is_none() { + st.compiled = Some(Arc::new(st.snapshot_routers())); + st.rebuild_snapshot(); + } + Arc::clone(st.compiled.as_ref().expect("just populated")) + }) } /// Synchronous RSGI handling for **openapi**, **404**, **405**, and **trivial matched routes**: diff --git a/src/lib.rs b/src/lib.rs index e195c34..587532e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -748,9 +748,14 @@ impl App { .map_err(|e| { pyo3::exceptions::PyRuntimeError::new_err(format!("DB connect error: {}", e)) })?; - let mut st = state.write(); - st.db_pool = Some(pool); - st.rebuild_snapshot(); + // GIL before the write lock (see the comment on `ensure_compiled_snapshot` in + // dispatch.rs / issue #205): this future runs on a tokio worker thread without + // the GIL, and `rebuild_snapshot` needs it to clone `Py` handles. + Python::with_gil(|_py| { + let mut st = state.write(); + st.db_pool = Some(pool); + st.rebuild_snapshot(); + }); Ok(()) }) } @@ -759,12 +764,12 @@ impl App { fn close_database<'py>(&self, py: Python<'py>) -> PyResult> { let state = self.state.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let pool = { + let pool = Python::with_gil(|_py| { let mut st = state.write(); let p = st.db_pool.take(); st.rebuild_snapshot(); p - }; + }); if let Some(p) = pool { p.close().await; } From 84c4f08024c8ea3c1be2a5f6928398a8b444df22 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 12:39:38 +0300 Subject: [PATCH 02/32] fix(dispatch): reject oversized body via Content-Length before reading it Granian's protocol() reads the entire request body into memory in one shot; the OXYROUTE_MAX_BODY_BYTES check ran only after that read and after a second copy into PooledBuffer, so a client could force a multi-GB allocation per request regardless of the configured limit. Check the declared Content-Length against the limit before awaiting protocol() and reject with 413 up front. Bodies without a Content-Length (chunked transfer-encoding) still fall through to the existing post-read check. Closes #206 --- src/dispatch.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/dispatch.rs b/src/dispatch.rs index 9176787..f8a64b3 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -705,6 +705,31 @@ pub async fn run_rsgi( routes_arc[route_idx].extra.path_template.clone(), ) }); + let max = form::max_body_bytes(); + if should_read_body { + // Reject an oversized declared body *before* awaiting `protocol()` — Granian's + // `protocol()` reads the whole body into memory in one shot, so checking the limit + // only after that read still lets an attacker force a multi-GB allocation per request + // (issue #206). A `Content-Length` over the limit is rejected up front; bodies sent + // without `Content-Length` (e.g. chunked transfer-encoding) still fall through to the + // post-read check below. + let declared_len: Option = Python::with_gil(|py| { + let s = scope.bind(py); + let headers = s.getattr("headers").ok()?; + header_get_lax(&headers, "content-length")?.parse().ok() + }); + if let Some(len) = declared_len { + if len > max { + return response::send_text( + &protocol, + 413, + r#"{"error":"payload too large"}"#, + "application/json; charset=utf-8", + ) + .await; + } + } + } let mut body_bytes: PooledBuffer = if should_read_body { let read_fut = Python::with_gil(|py| { let p = protocol.bind(py); @@ -726,7 +751,6 @@ pub async fn run_rsgi( } Ok(()) })?; - let max = form::max_body_bytes(); if (body.len() as u64) > max { return response::send_text( &protocol, From df6f14c643697ae87daa0ba18231476a6297d685 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 12:48:03 +0300 Subject: [PATCH 03/32] fix(rate-limit): stop trusting X-Forwarded-For / X-Real-IP by default The default "ip" rate-limit key strategy honored X-Forwarded-For and X-Real-IP unconditionally, with no trusted-proxy check. Any client could bypass its rate limit by sending a fresh value on each request, or frame another client by spoofing its IP in the header. The default "ip" strategy now keys on the actual TCP peer address (scope.client) only, which the client cannot control. A new opt-in strategy, "trusted-forwarded-ip" (aliased "forwarded-ip"), restores the X-Forwarded-For / X-Real-IP behavior for deployments that are actually behind a reverse proxy overwriting those headers. Also fixes scope.client parsing: Granian's RSGI scope exposes it as a "host:port" string, but the old code indexed it with get_item(0) as if it were an ASGI-style (host, port) tuple, silently returning only the first character of the string. scope_client_host() now handles both the RSGI string form and the ASGI/test-scope tuple form. Closes #207 --- src/rate_limit.rs | 56 +++++++++++++++++++++++++++++++++++----- tests/test_rate_limit.py | 33 ++++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 541a312..e7b1ac0 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -13,7 +13,14 @@ const MAX_SHARD_ENTRIES: usize = 8192; #[derive(Clone, Debug, PartialEq, Eq)] pub enum RateLimitKeyStrategy { + /// The actual TCP peer address (``scope.client``). Cannot be spoofed by the client — + /// the safe default. Ip, + /// `X-Forwarded-For` / `X-Real-IP`, falling back to ``scope.client``. **Only safe behind + /// a trusted reverse proxy that overwrites these headers** — otherwise any client can pick + /// its own rate-limit bucket, or frame another client's IP, by setting the header itself + /// (issue #207). Opt in explicitly with ``rate_limit_key="trusted-forwarded-ip"``. + TrustedForwardedIp, Header(String), Global, } @@ -23,6 +30,10 @@ impl RateLimitKeyStrategy { let trimmed = s.trim(); if trimmed.eq_ignore_ascii_case("ip") || trimmed.eq_ignore_ascii_case("client") { Self::Ip + } else if trimmed.eq_ignore_ascii_case("trusted-forwarded-ip") + || trimmed.eq_ignore_ascii_case("forwarded-ip") + { + Self::TrustedForwardedIp } else if trimmed.eq_ignore_ascii_case("global") { Self::Global } else if let Some(hdr) = trimmed.strip_prefix("header:") { @@ -192,6 +203,37 @@ impl RateLimiter { } } +/// Read the peer address off ``scope.client``. Granian's RSGI scope exposes this as a +/// ``"host:port"`` string; the ASGI bridge / test scopes expose an ASGI-style ``(host, port)`` +/// tuple. Handles both so the "safe" strategies below get the real peer, not (e.g.) the first +/// character of a ``"host:port"`` string via a stray ``__getitem__``. +fn scope_client_host(scope: &pyo3::Bound<'_, pyo3::PyAny>) -> Option { + let client = scope.getattr("client").ok()?; + if let Ok(s) = client.extract::() { + if s.is_empty() { + return None; + } + // "host:port" (IPv4) or "[::1]:port" (IPv6) — split off the trailing ":port". + if let Some(rest) = s.strip_prefix('[') { + if let Some(end) = rest.find(']') { + return Some(format!("[{}]", &rest[..end])); + } + } + return match s.rsplit_once(':') { + Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => { + Some(host.to_string()) + } + _ => Some(s), + }; + } + if let Ok(item) = client.get_item(0) { + if let Ok(ip) = item.extract::() { + return Some(ip); + } + } + None +} + /// Extract rate limit key from Granian / ASGI scope based on strategy. pub fn extract_rate_limit_key( scope: &pyo3::Bound<'_, pyo3::PyAny>, @@ -208,6 +250,12 @@ pub fn extract_rate_limit_key( "unknown".to_string() } RateLimitKeyStrategy::Ip => { + if let Some(ip) = scope_client_host(scope) { + return ip; + } + "127.0.0.1".to_string() + } + RateLimitKeyStrategy::TrustedForwardedIp => { if let Ok(headers) = scope.getattr("headers") { if let Some(xf) = crate::params::header_get_lax(&headers, "x-forwarded-for") { if let Some(first_ip) = xf.split(',').next() { @@ -224,12 +272,8 @@ pub fn extract_rate_limit_key( } } } - if let Ok(client) = scope.getattr("client") { - if let Ok(item) = client.get_item(0) { - if let Ok(ip) = item.extract::() { - return ip; - } - } + if let Some(ip) = scope_client_host(scope) { + return ip; } "127.0.0.1".to_string() } diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index f5c6a95..04e10b4 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -45,9 +45,11 @@ async def _run() -> None: assert int(r3.headers.get("ratelimit-reset", "0")) >= 1 assert int(r3.headers.get("retry-after", "0")) >= 1 - # Different IP (via x-forwarded-for) is not blocked - r_other_ip = await c.get("/limited", headers={"x-forwarded-for": "198.51.100.1"}) - assert r_other_ip.status_code == 200 + # Default "ip" strategy keys on the real peer address, not a client-supplied + # X-Forwarded-For (issue #207): the header must NOT let a client escape the + # bucket it's already exhausted. + r_spoofed = await c.get("/limited", headers={"x-forwarded-for": "198.51.100.1"}) + assert r_spoofed.status_code == 429 # 2. Header-keyed rate limiting rh1 = await c.get("/custom-header", headers={"x-api-key": "client-A"}) @@ -70,3 +72,28 @@ async def _run() -> None: assert rr3.status_code == 429 asyncio.run(_run()) + + +def test_trusted_forwarded_ip_opt_in() -> None: + """``rate_limit_key=\"trusted-forwarded-ip\"`` opts into honoring X-Forwarded-For / + X-Real-IP — only safe behind a reverse proxy that overwrites those headers (issue #207).""" + app = App() + + @app.get("/limited", rate_limit="1/minute", rate_limit_key="trusted-forwarded-ip") + def handle_limited() -> dict[str, str]: + return {"status": "ok"} + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r1 = await c.get("/limited", headers={"x-forwarded-for": "203.0.113.1"}) + assert r1.status_code == 200 + + r2 = await c.get("/limited", headers={"x-forwarded-for": "203.0.113.1"}) + assert r2.status_code == 429 + + # A different forwarded IP gets its own bucket. + r3 = await c.get("/limited", headers={"x-forwarded-for": "203.0.113.2"}) + assert r3.status_code == 200 + + asyncio.run(_run()) From 5d30a86e87227f9f48754e1bbd53c0ebecdb79f8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 12:48:06 +0300 Subject: [PATCH 04/32] docs(rate-limiting): document trusted-forwarded-ip and the ip default fix See #207. --- docs/rate-limiting.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index b7b6267..d2eaaf8 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -40,13 +40,25 @@ The `rate_limit` string accepts `/`: By default, rate limiting is tracked per client IP (`rate_limit_key="ip"`). You can customize the key: ### 1. Client IP (default) -Tracks client IP using the `client` tuple or `X-Forwarded-For` / `X-Real-IP` headers when behind reverse proxies: +Tracks the **actual TCP peer address** (`scope.client`). This cannot be spoofed by the client, +and is the safe default: ```python @app.get("/login", rate_limit="5/minute", rate_limit_key="ip") def login() -> dict[str, str]: return {"status": "ok"} ``` +### 1b. Forwarded IP (opt-in, only behind a trusted reverse proxy) +`rate_limit_key="trusted-forwarded-ip"` honors `X-Forwarded-For` / `X-Real-IP` (falling back to +the peer address if neither is present). **Only use this when every request actually passes +through a reverse proxy that overwrites these headers** — otherwise any client can set its own +`X-Forwarded-For` value to dodge the limit, or spoof another client's IP to get it blocked: +```python +@app.get("/login", rate_limit="5/minute", rate_limit_key="trusted-forwarded-ip") +def login() -> dict[str, str]: + return {"status": "ok"} +``` + ### 2. Header-Based (e.g. API Keys or User IDs) Rate limit by custom HTTP header (e.g. `header:X-API-Key` or `header:Authorization`): ```python From 56bbd7cf98b16dcd0a61a06711ae1f8573f4f244 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 12:58:13 +0300 Subject: [PATCH 05/32] fix(rate-limit): bound shard memory with eviction instead of unbounded growth + O(n) sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each shard's HashMap could grow without limit under a flood of unique keys, since the periodic retain() only evicted entries idle longer than two windows. Once past MAX_SHARD_ENTRIES, every request scanned the whole shard under its mutex — O(n) per request on both the memory and CPU axes, a DoS vector especially combined with a client-supplied key (e.g. the forwarded-IP strategy). Replace the periodic full-shard sweep with strict per-insert bounded eviction: when a shard is at MAX_SHARD_ENTRIES and a new key arrives, evict one existing entry before inserting. This keeps check() O(1) per request regardless of key cardinality, with no full-shard scan. Also cap the key string itself at MAX_KEY_LEN bytes, since keys can come from client-controlled input (e.g. a forwarded-IP header value) and an oversized key would otherwise inflate a shard's memory footprint per request. Closes #208 --- src/rate_limit.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index e7b1ac0..50fa59a 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -10,6 +10,10 @@ use pyo3::prelude::*; const NUM_SHARDS: usize = 16; const MAX_SHARD_ENTRIES: usize = 8192; +/// Cap on the key string itself (issue #208): keys can come from client-controlled input +/// (e.g. a forwarded-IP header value), so without a bound a single request could grow a +/// shard's memory footprint arbitrarily via one oversized key. +const MAX_KEY_LEN: usize = 256; #[derive(Clone, Debug, PartialEq, Eq)] pub enum RateLimitKeyStrategy { @@ -104,6 +108,18 @@ impl RateLimitConfig { } } +/// Truncate `key` to `MAX_KEY_LEN` bytes on a valid UTF-8 boundary. +fn truncate_key(key: &str) -> &str { + if key.len() <= MAX_KEY_LEN { + return key; + } + let mut end = MAX_KEY_LEN; + while end > 0 && !key.is_char_boundary(end) { + end -= 1; + } + &key[..end] +} + #[derive(Clone, Copy, Debug)] struct BucketState { tokens: f64, @@ -125,6 +141,10 @@ pub enum RateLimitDecision { pub struct RateLimiter { pub limit: u64, + /// Kept for introspection alongside `limit`/`refill_rate_per_sec`; the eviction path no + /// longer reads it directly (see issue #208 — replaced the periodic `retain` sweep with + /// bounded per-shard eviction). + #[allow(dead_code)] pub window_secs: f64, pub refill_rate_per_sec: f64, pub key_strategy: RateLimitKeyStrategy, @@ -152,13 +172,19 @@ impl RateLimiter { } pub fn check(&self, key: &str) -> RateLimitDecision { + let key = truncate_key(key); let now = Instant::now(); let idx = self.shard_index(key); let mut shard = self.shards[idx].lock(); - if shard.len() > MAX_SHARD_ENTRIES { - let expire_cutoff = self.window_secs * 2.0; - shard.retain(|_, v| (now - v.last_update).as_secs_f64() < expire_cutoff); + // Bound each shard's memory strictly: if this is a new key and the shard is already + // at capacity, evict one existing entry first instead of letting the map grow without + // limit (issue #208). This keeps `check` O(1) per request even under a flood of unique + // keys — no full-shard scan on the hot path, unlike the previous periodic `retain`. + if shard.len() >= MAX_SHARD_ENTRIES && !shard.contains_key(key) { + if let Some(evict) = shard.keys().next().cloned() { + shard.remove(&evict); + } } let limit_f64 = self.limit as f64; @@ -353,4 +379,48 @@ mod tests { RateLimitDecision::Denied { .. } => panic!("should be allowed"), } } + + #[test] + fn test_shard_bounded_under_unique_key_flood() { + // issue #208: a flood of unique keys must not grow a shard's map past + // MAX_SHARD_ENTRIES, and `check` must keep working (not panic/hang) throughout. + let config = RateLimitConfig { + limit: 5, + window_secs: 60.0, + key_strategy: RateLimitKeyStrategy::Ip, + }; + let limiter = RateLimiter::new(config); + + for i in 0..(MAX_SHARD_ENTRIES * NUM_SHARDS * 2) { + let key = format!("unique-key-{i}"); + limiter.check(&key); + } + + for shard in &limiter.shards { + assert!( + shard.lock().len() <= MAX_SHARD_ENTRIES, + "shard grew past MAX_SHARD_ENTRIES" + ); + } + } + + #[test] + fn test_key_length_is_capped() { + let config = RateLimitConfig { + limit: 5, + window_secs: 60.0, + key_strategy: RateLimitKeyStrategy::Ip, + }; + let limiter = RateLimiter::new(config); + + let huge_key = "x".repeat(1_000_000); + limiter.check(&huge_key); + + let total_len: usize = limiter + .shards + .iter() + .flat_map(|s| s.lock().keys().map(|k| k.len()).collect::>()) + .sum(); + assert!(total_len <= MAX_KEY_LEN); + } } From c0696509475451b79e19dbceb7654f5a83dc3f0d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 13:05:01 +0300 Subject: [PATCH 06/32] fix(middleware): apply_cors/apply_csrf compose instead of clobbering the middleware stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_middleware() replaces the entire single-slot request-middleware list, but apply_cors() and apply_csrf() both called it internally. Calling add_middleware(auth) followed by apply_cors(...) silently deleted the auth middleware; calling apply_csrf(...) followed by apply_cors(...) silently deleted the CSRF guard. No error, no warning — a composed security stack just lost a layer. - Add App.add_middleware(handler, phase="request"|"response"|"both"), wiring the previously-unused native add_middleware pymethod. - apply_cors() and apply_csrf() now use add_middleware() instead of set_middleware(), so they compose with anything already registered (including with each other) instead of replacing it. - set_middleware() now warns when it would clobber existing middleware registered via add_middleware, for callers who still use the single-slot API directly. Closes #209 --- oxyroute/app.py | 32 +++++++++++++++++++++ oxyroute/cors.py | 11 +++++--- oxyroute/csrf.py | 11 ++++---- tests/test_csrf_units.py | 3 +- tests/test_middleware.py | 61 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/oxyroute/app.py b/oxyroute/app.py index 5894f84..09be025 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -190,6 +190,9 @@ def __init__( self.state: SimpleNamespace = SimpleNamespace() self._docs_ui: str | None = normalize_docs_ui(docs_ui) self._docs_mounted: bool = False + # Tracks request-middleware count so `set_middleware` can warn before it silently + # clobbers anything registered via `add_middleware` (issue #209). + self._request_mw_count: int = 0 if ( openapi_description is not None or openapi_contact is not None @@ -331,8 +334,37 @@ def set_middleware(self, handler: Callable[..., Any] | None) -> None: (e.g. :class:`oxyroute.Response` or a ``dict`` with ``status`` / ``body`` / ``headers``); the response is sent and routing / body read is skipped. Runs **before** the request body is read (e.g. for CORS preflight). + + **Replaces** the entire request-middleware stack, including anything registered via + :meth:`add_middleware` (or :func:`oxyroute.cors.apply_cors` / :func:`oxyroute.csrf.apply_csrf`, + which use :meth:`add_middleware` internally). Prefer :meth:`add_middleware` to compose + multiple middlewares instead of replacing what is already registered (issue #209). """ + if handler is not None and self._request_mw_count > 0: + import warnings + + warnings.warn( + f"set_middleware() is replacing {self._request_mw_count} already-registered " + "request middleware(s) (e.g. from add_middleware/apply_cors/apply_csrf). " + "Use add_middleware() instead to compose middlewares without clobbering them.", + stacklevel=2, + ) self._app.set_middleware(handler) + self._request_mw_count = 1 if handler is not None else 0 + + def add_middleware(self, handler: Callable[..., Any], phase: str = "request") -> None: + """ + Append ``handler`` to the middleware stack instead of replacing it. + + ``phase`` is one of ``"request"`` (runs before routing, same contract as + :meth:`set_middleware`), ``"response"`` (runs on the outgoing response, before + CORS / security-header merging), or ``"both"``. Request middlewares run in + registration order; the first one to return a non-``None`` value short-circuits + the rest and is sent as the response. + """ + if phase in ("request", "both"): + self._request_mw_count += 1 + self._app.add_middleware(handler, phase) def set_cors(self, config: Any | None) -> None: """ diff --git a/oxyroute/cors.py b/oxyroute/cors.py index c119cea..4e44793 100644 --- a/oxyroute/cors.py +++ b/oxyroute/cors.py @@ -136,12 +136,15 @@ def apply_cors( ) -> None: """ Register CORS: stores ``config`` for native response merging and installs preflight - handling via :meth:`oxyroute.app.App.set_middleware`. If you already use middleware for + handling via :meth:`oxyroute.app.App.add_middleware`. If you already use middleware for other work, pass it as ``chain`` so it runs when the request is not a CORS preflight (your handler runs after the CORS layer returns ``None`` for continuation). - **Order:** this replaces ``set_middleware`` with an internal function. To combine with - another pre-route callback, use ``apply_cors(..., chain=your_middleware)``. + **Order:** this *appends* a request middleware rather than replacing the stack, so it + composes with anything already registered via :meth:`oxyroute.app.App.add_middleware` + (including another :func:`apply_cors` / :func:`oxyroute.csrf.apply_csrf` call) instead of + silently deleting it (issue #209). To combine with another pre-route callback that must run + strictly after the CORS preflight check, use ``apply_cors(..., chain=your_middleware)``. """ app.set_cors(config) @@ -153,4 +156,4 @@ def _cors_middleware(scope: Any, protocol: Any) -> Response | None: return chain(scope, protocol) return None - app.set_middleware(_cors_middleware) + app.add_middleware(_cors_middleware, phase="request") diff --git a/oxyroute/csrf.py b/oxyroute/csrf.py index 0d72d52..0e619d7 100644 --- a/oxyroute/csrf.py +++ b/oxyroute/csrf.py @@ -128,10 +128,11 @@ def apply_csrf( chain: _Middleware | None = None, ) -> None: """ - Installs **one** :meth:`oxyroute.app.App.set_middleware` that runs :meth:`CSRFConfig.guard` - first, then ``chain`` (if any), then continues routing. Replaces any previous - pre-route callback — combine manually or use :func:`csrf_layer` inside - :func:`oxyroute.cors.apply_cors`. + Appends (via :meth:`oxyroute.app.App.add_middleware`) a request middleware that runs + :meth:`CSRFConfig.guard` first, then ``chain`` (if any), then continues routing. Composes + with anything already registered via :meth:`oxyroute.app.App.add_middleware` instead of + replacing it (issue #209) — combine manually, or use :func:`csrf_layer` inside + :func:`oxyroute.cors.apply_cors` for strict ordering relative to the CORS preflight check. """ def _mw(scope: Any, protocol: Any) -> Response | None: @@ -142,4 +143,4 @@ def _mw(scope: Any, protocol: Any) -> Response | None: return chain(scope, protocol) return None - app.set_middleware(_mw) + app.add_middleware(_mw, phase="request") diff --git a/tests/test_csrf_units.py b/tests/test_csrf_units.py index 6c9bfc9..e7ede05 100644 --- a/tests/test_csrf_units.py +++ b/tests/test_csrf_units.py @@ -31,7 +31,8 @@ class _AppStub: def __init__(self) -> None: self.middleware = None - def set_middleware(self, mw): # type: ignore[no-untyped-def] + def add_middleware(self, mw, phase="request"): # type: ignore[no-untyped-def] + assert phase == "request" self.middleware = mw app = _AppStub() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 94ce8b1..7d6c552 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -3,9 +3,12 @@ from __future__ import annotations import asyncio +import warnings import httpx from oxyroute import App, Response +from oxyroute.cors import CORSConfig, apply_cors +from oxyroute.csrf import CSRFConfig, apply_csrf from oxyroute.testing import asgi_test_app @@ -43,3 +46,61 @@ async def _run() -> None: assert n == 0 asyncio.run(_run()) + + +def test_add_middleware_then_apply_cors_does_not_clobber_auth() -> None: + """issue #209: add_middleware(auth) followed by apply_cors(...) must not silently + delete the auth middleware.""" + calls: list[str] = [] + + def auth_mw(scope, _protocol): # type: ignore[no-untyped-def] + calls.append("auth") + return None + + app = App() + app.add_middleware(auth_mw) + apply_cors(app, CORSConfig()) + + @app.get("/x") + def _x() -> str: + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/x") + assert r.status_code == 200, r.text + + asyncio.run(_run()) + assert calls == ["auth"] + + +def test_apply_csrf_then_apply_cors_compose_instead_of_clobbering() -> None: + """issue #209: calling apply_csrf then apply_cors must not delete the CSRF guard.""" + app = App() + apply_csrf(app, CSRFConfig()) + apply_cors(app, CORSConfig()) + + @app.post("/x") + def _x() -> str: + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + # unsafe method, no CSRF token -> the CSRF guard (still registered) must block it + r = await c.post("/x") + assert r.status_code == 403, r.text + + asyncio.run(_run()) + + +def test_set_middleware_warns_when_clobbering_add_middleware() -> None: + app = App() + app.add_middleware(lambda scope, protocol: None) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + app.set_middleware(lambda scope, protocol: None) + + assert any("set_middleware" in str(x.message) for x in w) From 1b277fc661f99405e540099b8ab2387d4cf8580c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 13:11:51 +0300 Subject: [PATCH 07/32] fix(dispatch): remove /test_db prototype endpoint GET /test_db was a hardcoded benchmark prototype (issue #55) left in the hot path. If a DB pool was configured it silently shadowed any user-registered route at that exact path, and on query error it returned the raw sqlx error string assembled via format! without escaping - a backend detail leak and a JSON-injection risk if the error text contained quotes. Removed both the sync short-circuit bailout and the async handler. FrozenState.db_pool was only read by this endpoint - dropped the now- dead field along with it; dependency-injected DBQuery execution reads AppState.db_pool directly and is unaffected. Closes #210 --- src/dispatch.rs | 32 -------------------------------- src/state.rs | 3 --- 2 files changed, 35 deletions(-) diff --git a/src/dispatch.rs b/src/dispatch.rs index f8a64b3..0abc15a 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -355,9 +355,6 @@ pub fn try_rsgi_sync_short_circuit( if !snapshot.request_middleware.is_empty() || !snapshot.response_middleware.is_empty() { return Ok(None); } - if method == "GET" && path == "/test_db" { - return Ok(None); // defer to async run_rsgi for the prototype - } if (method == "GET" || method == "HEAD") && path == "/openapi.json" && snapshot.include_openapi { let _ = protocol_py.setattr(py, "__oxyroute_path_template__", "/openapi.json"); @@ -503,35 +500,6 @@ pub async fn run_rsgi( } return response::send_str(&protocol, 200, &doc, "application/json; charset=utf-8").await; } - // Prototype: Issue 55 (sqlx integration benchmark path) - if method == "GET" && path == "/test_db" { - let _ = - Python::with_gil(|py| protocol.setattr(py, "__oxyroute_path_template__", "/test_db")); - if let Some(pool) = snapshot.db_pool.as_ref() { - use sqlx::Row; - match sqlx::query("SELECT 1 as num").fetch_one(pool).await { - Ok(row) => { - let num: i32 = row.get("num"); - return response::send_str( - &protocol, - 200, - &format!(r#"{{"num":{num}}}"#), - "application/json; charset=utf-8", - ) - .await; - } - Err(e) => { - return response::send_str( - &protocol, - 500, - &format!(r#"{{"error":"{e}"}}"#), - "application/json; charset=utf-8", - ) - .await; - } - } - } - } for mw in snapshot.request_middleware.iter() { let out: Py = match Python::with_gil(|py| { let f = mw.bind(py); diff --git a/src/state.rs b/src/state.rs index 06194dc..dd94f92 100644 --- a/src/state.rs +++ b/src/state.rs @@ -205,7 +205,6 @@ impl AppState { response_middleware: Arc::clone(&response_middleware), exception_handlers: Arc::clone(&exception_handlers), include_openapi: true, - db_pool: None, }); Self { routes, @@ -248,7 +247,6 @@ impl AppState { response_middleware: Arc::clone(&self.response_middleware), exception_handlers: Arc::clone(&self.exception_handlers), include_openapi: self.include_openapi, - db_pool: self.db_pool.clone(), }); }); } @@ -290,7 +288,6 @@ pub struct FrozenState { pub response_middleware: Arc>>, pub exception_handlers: ExceptionHandlerList, pub include_openapi: bool, - pub db_pool: Option, } pub type HotSnapshot = Arc; From b1c18ea2771b4161264f833904b2f6859b792b8e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 13:20:45 +0300 Subject: [PATCH 08/32] fix(dispatch): stop misreading list/dict handler returns as raw bytes/wrong status map_handler_return() tried extract::>() before checking for a plain dict/list. pyo3 happily extracts any Python sequence of small ints as Vec - including an empty list - so `return []` sent an empty application/octet-stream body instead of JSON `[]`, and `return [1, 2, 3]` sent raw bytes \x01\x02\x03 instead of JSON `[1,2,3]`. Separately, any dict containing both a "status" and "body" key was unconditionally treated as a structured HTTP response, so ordinary JSON data shaped that way (e.g. {"status": 1, "body": "x"}) had its HTTP status silently set to 1 instead of being returned as data. Fix: - Check for PyDict/PyList before the String/Vec extraction attempts, so JSON payloads always fall through to JSON serialization instead of being coerced to bytes. - Require "status" to be a plausible HTTP status code (100-599, RFC 9110 Sec 15) before treating a dict as a structured response, preserving the existing documented {"status": ..., "body": ...} shorthand (docs/usage.md, tests/test_exception_handlers.py) for real status codes while letting ambiguous data round-trip as JSON. Closes #211 --- src/dispatch.rs | 69 ++++++++++++--------- tests/test_handler_return_mapping.py | 90 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 tests/test_handler_return_mapping.py diff --git a/src/dispatch.rs b/src/dispatch.rs index 0abc15a..cea80bd 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1341,27 +1341,11 @@ fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { if is_oxyroute_response(py, b)? { return structured_from_response_attrs(py, b); } - if let Ok(s) = b.extract::() { - return Ok(HandlerMap::Simple { - status: 200, - body: SimpleBody::Owned(s.into_bytes()), - content_type: "text/plain; charset=utf-8".to_string(), - }); - } - if let Ok(s) = b.extract::<&str>() { - return Ok(HandlerMap::Simple { - status: 200, - body: SimpleBody::Owned(s.as_bytes().to_vec()), - content_type: "text/plain; charset=utf-8".to_string(), - }); - } - if let Ok(buf) = b.extract::>() { - return Ok(HandlerMap::Simple { - status: 200, - body: SimpleBody::Owned(buf), - content_type: "application/octet-stream".to_string(), - }); - } + // `dict` / `list` are JSON payloads by convention, not raw byte sequences — this must run + // *before* the `String` / `Vec` coercion attempts below. pyo3 happily extracts any + // Python sequence of small ints (including `[]`) as `Vec`, so without this a JSON + // handler return like `[]` or `[1, 2, 3]` was silently reinterpreted as raw bytes with + // `content-type: application/octet-stream` instead of being JSON-serialized (issue #211). if let Ok(d) = b.downcast::() { let h = d.get_item("headers")?; let c = d.get_item("cookies")?; @@ -1380,14 +1364,45 @@ fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { let st = d.get_item("status")?; let bd = d.get_item("body")?; if let (Some(sc), Some(body)) = (st, bd) { - if let (Ok(code), Ok(bstr)) = (sc.extract::(), body.str()) { - return Ok(HandlerMap::Simple { - status: code, - body: SimpleBody::Owned(bstr.to_string().into_bytes()), - content_type: "text/plain; charset=utf-8".to_string(), - }); + // Require a plausible HTTP status code (100-599, RFC 9110 §15) before treating this + // dict as a structured response — a plain JSON payload that happens to also have + // "status" and "body" keys (e.g. `{"status": 1, "body": "x"}`) must round-trip as + // data, not be reinterpreted as an HTTP response with status 1 (issue #211). + if let Ok(code) = sc.extract::() { + if (100..=599).contains(&code) { + if let Ok(bstr) = body.str() { + return Ok(HandlerMap::Simple { + status: code, + body: SimpleBody::Owned(bstr.to_string().into_bytes()), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + } } } + // Not a structured response shape — fall through to JSON serialization below. + } else if !b.is_instance_of::() { + if let Ok(s) = b.extract::() { + return Ok(HandlerMap::Simple { + status: 200, + body: SimpleBody::Owned(s.into_bytes()), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + if let Ok(s) = b.extract::<&str>() { + return Ok(HandlerMap::Simple { + status: 200, + body: SimpleBody::Owned(s.as_bytes().to_vec()), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + if let Ok(buf) = b.extract::>() { + return Ok(HandlerMap::Simple { + status: 200, + body: SimpleBody::Owned(buf), + content_type: "application/octet-stream".to_string(), + }); + } } let jmod = py.import("json")?; let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?; diff --git a/tests/test_handler_return_mapping.py b/tests/test_handler_return_mapping.py new file mode 100644 index 0000000..601a3b5 --- /dev/null +++ b/tests/test_handler_return_mapping.py @@ -0,0 +1,90 @@ +"""Handler return-value -> HTTP response mapping (issue #211). + +``list``/``dict`` handler returns must always be JSON-serialized, never misread as raw +bytes; a dict that merely happens to contain "status" and "body" keys as ordinary data +must round-trip as JSON unless "status" is a plausible HTTP status code. +""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App +from oxyroute.testing import asgi_test_app + + +def _client(app: App) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +def test_empty_list_is_json_not_octet_stream() -> None: + app = App() + + @app.get("/empty") + def empty() -> list: + return [] + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/empty") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/json") + assert r.json() == [] + + asyncio.run(_run()) + + +def test_int_list_is_json_not_raw_bytes() -> None: + app = App() + + @app.get("/ids") + def ids() -> list: + return [1, 2, 3] + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/ids") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/json") + assert r.json() == [1, 2, 3] + + asyncio.run(_run()) + + +def test_dict_with_out_of_range_status_roundtrips_as_data() -> None: + """A dict with "status"/"body" keys that isn't a plausible HTTP status (100-599) + must not be reinterpreted as a structured response.""" + app = App() + + @app.get("/data") + def data() -> dict: + return {"status": 1, "body": "x"} + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/data") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/json") + assert r.json() == {"status": 1, "body": "x"} + + asyncio.run(_run()) + + +def test_dict_with_valid_status_and_body_is_still_a_structured_response() -> None: + """Existing, documented shorthand: a dict with a real HTTP status code + body sets + the response status (docs/usage.md).""" + app = App() + + @app.get("/teapot") + def teapot() -> dict: + return {"status": 418, "body": "I'm a teapot"} + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/teapot") + assert r.status_code == 418 + assert r.text == "I'm a teapot" + + asyncio.run(_run()) From 9f57546c762ac0b553144fc8b8664f9ac439054d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 29 Sep 2026 13:29:49 +0300 Subject: [PATCH 09/32] fix(routing): wildcard *path params never get schema-lite type coercion Every path param value, including a `*name` catch-all capturing an arbitrary sub-path, went through the same int/float/bool schema-lite coercion as a single-segment `:name` param. This broke StaticFiles (mounted via app.mount(...) with a `*path` wildcard) on any file whose name looked numeric or boolean: e.g. GET /static/42 arrived at the handler as an int, and int has no .split("/"), so the route 500'd. It also silently mistyped ownership/auth checks comparing a wildcard path segment against a string ID. Track which path param names come from a `*name` catch-all at route- registration time (RouteExtra::wildcard_params) and skip coercion for just those params at dispatch time. Single-segment `:name` coercion (fully covered by the existing test_path_param_coercion.py suite) is unchanged. Closes #212 --- src/dispatch.rs | 11 ++++++++++- src/lib.rs | 25 +++++++++++++++++++++++++ src/state.rs | 6 ++++++ tests/test_path_param_coercion.py | 21 +++++++++++++++++++++ tests/test_static.py | 16 ++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/dispatch.rs b/src/dispatch.rs index cea80bd..eb784fd 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -634,6 +634,7 @@ pub async fn run_rsgi( body_model, body_param_name, rate_limiter, + wildcard_params, ) = Python::with_gil(|_py| -> PyResult<_> { let e = routes_arc .get(route_idx) @@ -653,6 +654,7 @@ pub async fn run_rsgi( e.body_model.clone(), e.extra.body_param_name.clone(), e.extra.rate_limiter.clone(), + Arc::clone(&e.extra.wildcard_params), )) })?; if let Some(ref rl) = rate_limiter { @@ -1111,7 +1113,14 @@ pub async fn run_rsgi( } let kwargs = PyDict::new(py); for (k, v) in params.iter() { - let vpy = value_for_path_param(py, v); + // A `*name` catch-all always stays `str` (it may contain slashes and is often a + // filename, e.g. under `StaticFiles`); only single-segment `:name` params get the + // int/float/bool schema-lite coercion (issue #212). + let vpy = if wildcard_params.contains(k) { + pyo3::types::PyString::new(py, v).into_any().unbind() + } else { + value_for_path_param(py, v) + }; kwargs.set_item(k, vpy)?; } if !query_map.is_empty() { diff --git a/src/lib.rs b/src/lib.rs index 587532e..ca2524f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,30 @@ fn handler_signature_kinds( Ok((names, w)) } +/// Names of `*name` catch-all path params in a matchit-style route template (as opposed to +/// single-segment `:name` params). See [`state::RouteExtra::wildcard_params`]. +fn wildcard_param_names(path: &str) -> HashSet { + let mut out = HashSet::new(); + let chars: Vec = path.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '*' { + i += 1; + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let name: String = chars[start..i].iter().collect(); + if !name.is_empty() { + out.insert(name); + } + } else { + i += 1; + } + } + out +} + fn parse_algorithm(s: &str) -> PyResult { jsonwebtoken::Algorithm::from_str(s).map_err(|_| { pyo3::exceptions::PyValueError::new_err( @@ -477,6 +501,7 @@ impl App { handler_param_names: Arc::new(handler_param_names), body_param_name: body_param_name.unwrap_or_else(|| "json".to_string()), rate_limiter, + wildcard_params: Arc::new(wildcard_param_names(&path)), }); routes.push(state::RouteEntry { handler, diff --git a/src/state.rs b/src/state.rs index dd94f92..0fcb932 100644 --- a/src/state.rs +++ b/src/state.rs @@ -108,6 +108,12 @@ pub struct RouteExtra { pub handler_param_names: Arc>, pub body_param_name: String, pub rate_limiter: Option>, + /// Path params captured by a `*name` catch-all segment (as opposed to a single-segment + /// `:name` param). A catch-all captures an arbitrary sub-path — potentially containing + /// slashes, and often a filename — so it must always reach the handler as `str`, never + /// coerced to `int`/`float`/`bool` (issue #212: this coercion previously broke + /// `StaticFiles` on any file whose name looked like a number, e.g. `/static/42`). + pub wildcard_params: Arc>, } /// Compact 32-byte route entry fitting comfortably inside a single 64-byte L1D cache line. diff --git a/tests/test_path_param_coercion.py b/tests/test_path_param_coercion.py index b42f91d..05d0520 100644 --- a/tests/test_path_param_coercion.py +++ b/tests/test_path_param_coercion.py @@ -66,3 +66,24 @@ def get_item(val: object): resp = client.get("/items/false") assert resp.status_code == 200 assert resp.json() == {"val": False, "type": "bool"} + + +def test_wildcard_path_param_is_never_coerced(): + """issue #212: a `*rest` catch-all always stays `str`, even for numeric-looking / + boolean-looking segments — only single-segment `:name` params get schema-lite coercion.""" + app = App() + + @app.get("/files/*rest") + def get_file(rest): + return {"rest": rest, "type": type(rest).__name__} + + with TestClient(app) as client: + for value, expected in [ + ("42", "42"), + ("true", "true"), + ("3.14", "3.14"), + ("a/b/42", "a/b/42"), + ]: + resp = client.get(f"/files/{value}") + assert resp.status_code == 200 + assert resp.json() == {"rest": expected, "type": "str"} diff --git a/tests/test_static.py b/tests/test_static.py index f9ee941..d98d5d5 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -20,6 +20,22 @@ def test_static_files(): assert resp.headers["content-type"] == "text/plain" +def test_static_files_numeric_filename(): + """issue #212: a wildcard `*path` catch-all must stay `str` — a file whose name looks + like an int/float/bool must not be schema-lite coerced and break `.split("/")`.""" + with TemporaryDirectory() as tmpdir: + with open(os.path.join(tmpdir, "42"), "w") as f: + f.write("the answer") + + app = App() + app.mount("/static", StaticFiles(tmpdir)) + + with TestClient(app) as client: + resp = client.get("/static/42") + assert resp.status_code == 200 + assert resp.content == b"the answer" + + def test_static_files_missing(): with TemporaryDirectory() as tmpdir: app = App() From a1908c199fd1a8ba8a009d48598ab8befa6f14c4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:35:02 +0300 Subject: [PATCH 10/32] fix(db): decode UUID, timestamp, and numeric types in DBQuery without unwraps --- Cargo.lock | 382 ++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 5 +- docs/database.md | 18 +++ src/db.rs | 82 ++++++++-- 4 files changed, 450 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf2efa4..f1d36a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -29,6 +38,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atoi" version = "2.0.0" @@ -77,6 +92,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -117,6 +156,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.0" @@ -125,7 +170,18 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", ] [[package]] @@ -195,6 +251,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -370,7 +432,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -523,7 +585,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -578,6 +640,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -586,8 +660,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -688,6 +762,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1016,6 +1114,7 @@ version = "0.6.0" dependencies = [ "base64", "bytes", + "chrono", "criterion", "form_urlencoded", "futures-util", @@ -1027,11 +1126,13 @@ dependencies = [ "parking_lot", "pyo3", "pyo3-async-runtimes", + "rust_decimal", "serde", "serde_json", "sqlx", "thiserror 1.0.69", "tokio", + "uuid", ] [[package]] @@ -1140,6 +1241,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1208,7 +1327,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1221,7 +1340,7 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1233,12 +1352,39 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1247,7 +1393,45 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1328,6 +1512,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1410,7 +1611,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1533,6 +1734,7 @@ dependencies = [ "base64", "bytes", "cfg-if", + "chrono", "crc", "crossbeam-queue", "either", @@ -1547,6 +1749,7 @@ dependencies = [ "log", "memchr", "percent-encoding", + "rust_decimal", "rustls", "serde", "serde_json", @@ -1557,6 +1760,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", "webpki-roots", ] @@ -1570,7 +1774,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -1593,7 +1797,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "thiserror 2.0.18", "tokio", "url", @@ -1608,6 +1812,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest 0.11.3", "dotenvy", @@ -1617,12 +1822,14 @@ dependencies = [ "generic-array", "log", "percent-encoding", + "rust_decimal", "serde", "sha1", "sha2 0.11.0", "sqlx-core", "thiserror 2.0.18", "tracing", + "uuid", ] [[package]] @@ -1635,6 +1842,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1648,7 +1856,8 @@ dependencies = [ "log", "md-5", "memchr", - "rand", + "rand 0.10.1", + "rust_decimal", "serde", "serde_json", "sha2 0.11.0", @@ -1657,6 +1866,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", + "uuid", "whoami", ] @@ -1667,6 +1877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", + "chrono", "flume", "form_urlencoded", "futures-channel", @@ -1682,6 +1893,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "url", + "uuid", ] [[package]] @@ -1718,6 +1930,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1726,7 +1949,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1761,7 +1984,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1772,7 +1995,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1866,6 +2089,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1886,7 +2139,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1961,6 +2214,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1989,6 +2252,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -2021,7 +2293,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2068,12 +2340,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2156,6 +2481,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -2181,7 +2521,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2202,7 +2542,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2222,7 +2562,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2262,7 +2602,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8229a4a..7fcea10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,10 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] } multer = "3.1" log = "0.4" parking_lot = "0.12" -sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "sqlite", "tls-rustls"] } +sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "sqlite", "tls-rustls", "uuid", "chrono", "rust_decimal"] } +uuid = { version = "1", default-features = false } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +rust_decimal = { version = "1", default-features = false } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/docs/database.md b/docs/database.md index 07bff6d..49ad02b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -60,6 +60,24 @@ The native Rust executor automatically maps and type-checks the following Python | `str` | `TEXT`, `VARCHAR`, `CHAR` | | `dict` / `list` | `JSON`, `JSONB` | +### Supported Result Decoding Types + +Rows returned from `DBQuery` are decoded into Python `dict`s according to their Postgres column type: + +| Postgres Type | Decoded Python Type / Representation | +|---|---| +| `NULL` | `None` | +| `BOOL` | `bool` | +| `INT2`, `INT4`, `INT8` | `int` | +| `FLOAT4`, `FLOAT8` | `float` | +| `NUMERIC`, `DECIMAL` | `str` (exact decimal representation) | +| `TEXT`, `VARCHAR`, `CHAR`, `NAME`, `CITEXT` | `str` | +| `JSON`, `JSONB` | `dict` / `list` / primitive | +| `UUID` | `str` (canonical UUID format) | +| `DATE`, `TIME`, `TIMETZ` | `str` (ISO-8601 date/time) | +| `TIMESTAMP`, `TIMESTAMPTZ` | `str` (ISO-8601 timestamp) | +| `BYTEA` | `bytes` | + --- ## Database Connection Pool Lifecycle diff --git a/src/db.rs b/src/db.rs index 2792144..28eb065 100644 --- a/src/db.rs +++ b/src/db.rs @@ -83,55 +83,105 @@ fn decode_pg_row_to_dict<'py>( let d = PyDict::new(py); for (i, col) in row.columns().iter().enumerate() { let name = col.name(); - let val_ref = row.try_get_raw(i).unwrap(); + let val_ref = row.try_get_raw(i).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to get raw column '{}' at index {}: {}", + name, i, e + )) + })?; if val_ref.is_null() { d.set_item(name, py.None())?; continue; } - let info = val_ref.type_info(); + let info = col.type_info(); let ty = info.name(); + + macro_rules! decode_val { + ($ty_dest:ty) => { + sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to decode column '{}' (type {}) as {}: {}", + name, + ty, + stringify!($ty_dest), + e + )) + })? + }; + } + match ty { "BOOL" => { - let v: bool = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: bool = decode_val!(bool); d.set_item(name, v)?; } "INT2" => { - let v: i16 = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: i16 = decode_val!(i16); d.set_item(name, v)?; } "INT4" => { - let v: i32 = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: i32 = decode_val!(i32); d.set_item(name, v)?; } "INT8" => { - let v: i64 = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: i64 = decode_val!(i64); d.set_item(name, v)?; } "FLOAT4" => { - let v: f32 = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: f32 = decode_val!(f32); d.set_item(name, v)?; } "FLOAT8" => { - let v: f64 = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: f64 = decode_val!(f64); d.set_item(name, v)?; } - "TEXT" | "VARCHAR" | "CHAR" | "\"CHAR\"" | "NAME" => { - let v: String = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + "NUMERIC" | "DECIMAL" => { + let v: rust_decimal::Decimal = decode_val!(rust_decimal::Decimal); + d.set_item(name, v.to_string())?; + } + "TEXT" | "VARCHAR" | "CHAR" | "\"CHAR\"" | "NAME" | "CITEXT" | "BPCHAR" => { + let v: String = decode_val!(String); d.set_item(name, v)?; } "JSON" | "JSONB" => { - let v: serde_json::Value = - sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref).unwrap(); + let v: serde_json::Value = decode_val!(serde_json::Value); let py_v = crate::schema::json_to_py(py, &v)?; d.set_item(name, py_v)?; } + "UUID" => { + let v: uuid::Uuid = decode_val!(uuid::Uuid); + d.set_item(name, v.to_string())?; + } + "DATE" => { + let v: chrono::NaiveDate = decode_val!(chrono::NaiveDate); + d.set_item(name, v.to_string())?; + } + "TIME" | "TIMETZ" => { + let v: chrono::NaiveTime = decode_val!(chrono::NaiveTime); + d.set_item(name, v.to_string())?; + } + "TIMESTAMP" => { + let v: chrono::NaiveDateTime = decode_val!(chrono::NaiveDateTime); + d.set_item(name, v.format("%Y-%m-%dT%H:%M:%S%.f").to_string())?; + } + "TIMESTAMPTZ" => { + let v: chrono::DateTime = decode_val!(chrono::DateTime); + d.set_item(name, v.to_rfc3339())?; + } + "BYTEA" => { + let v: Vec = decode_val!(Vec); + d.set_item(name, pyo3::types::PyBytes::new(py, &v))?; + } _ => { - // Fallback: try as string + // Fallback: try as string if possible if let Ok(v) = sqlx::Decode::<'_, sqlx::Postgres>::decode(val_ref) { let s: String = v; d.set_item(name, s)?; } else { - d.set_item(name, py.None())?; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Failed to decode column '{}' (unsupported Postgres type '{}')", + name, ty + ))); } } } @@ -143,7 +193,9 @@ pub async fn execute_query(pool: &sqlx::PgPool, db_query: &DBQuery) -> PyResult< let q = sqlx::query(sqlx::AssertSqlSafe(db_query.query.as_str())); let q = Python::with_gil(|py| -> PyResult<_> { let mut q = q; - let args_tuple = db_query.args.bind(py).downcast::().unwrap(); + let args_tuple = db_query.args.bind(py).downcast::().map_err(|e| { + pyo3::exceptions::PyTypeError::new_err(format!("Expected args to be a tuple: {}", e)) + })?; for arg in args_tuple.iter() { if arg.is_none() { From 46e7f86798cd8c1426c6a08d7d5ed9d43d88fae3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 11:17:42 +0300 Subject: [PATCH 11/32] fix(docs): pin CDN asset versions, add SRI integrity hashes and allow self-hosted assets --- docs/openapi.md | 17 ++++++++- oxyroute/app.py | 33 +++++++++++++++-- oxyroute/docs_ui.py | 83 ++++++++++++++++++++++++++++++++++++++----- tests/test_openapi.py | 31 ++++++++++++++++ 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/docs/openapi.md b/docs/openapi.md index ba8dad4..7e6ac4b 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -37,7 +37,22 @@ app.mount_docs("/api/docs", ui="swagger") | `mount_docs(path, ui=...)` | Mount at a custom path | | Spec URL | Built-in `/openapi.json` | -UI scripts load from **jsDelivr**. If you use `SecurityHeadersConfig` (or a strict CSP), allow `cdn.jsdelivr.net` in `script-src` / `style-src` for the docs route, or disable those headers on `/docs`. +UI scripts load from **jsDelivr** with pinned release versions and Subresource Integrity (SRI) hashes (`integrity="..."`, `crossorigin="anonymous"`) to protect against CDN or upstream supply-chain tampering: +- **Scalar**: pinned to `@scalar/api-reference@1.75.0` +- **Swagger UI**: pinned to `swagger-ui-dist@5.33.3` + +If you use `SecurityHeadersConfig` (or a strict CSP), allow `cdn.jsdelivr.net` in `script-src` / `style-src` for the docs route, or disable those headers on `/docs`. + +To self-host documentation assets without third-party CDN dependencies, pass custom URLs and optional SRI hashes to `app.mount_docs`: + +```python +app.mount_docs( + "/docs", + ui="swagger", + swagger_css_url="/static/swagger-ui.css", + swagger_js_url="/static/swagger-ui-bundle.js", +) +``` ## Title, version, info, and servers diff --git a/oxyroute/app.py b/oxyroute/app.py index 09be025..1877529 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -257,18 +257,45 @@ def mount_docs( *, ui: str = "scalar", openapi_url: str = "/openapi.json", + scalar_js_url: str | None = None, + scalar_js_sri: str | None = None, + swagger_css_url: str | None = None, + swagger_css_sri: str | None = None, + swagger_js_url: str | None = None, + swagger_js_sri: str | None = None, ) -> None: """ Register ``GET path`` serving Scalar or Swagger UI against ``openapi_url``. - UI assets load from a public CDN; set CSP ``script-src`` / ``style-src`` accordingly - (or disable security-header presets that block CDN scripts on the docs route). + UI assets load from a public CDN with pinned versions and Subresource Integrity (SRI); + set CSP ``script-src`` / ``style-src`` accordingly (or disable security-header presets + that block CDN scripts on the docs route). Custom or self-hosted asset URLs and SRIs + can be specified via ``scalar_js_url``, ``swagger_css_url``, ``swagger_js_url``, etc. """ ui_n = normalize_docs_ui(ui) if ui_n is None: raise ValueError("ui is required") path = path.rstrip("/") or "/docs" - html = docs_html(ui=ui_n, title=self.title, openapi_url=openapi_url) + + docs_kwargs: dict[str, str | None] = { + "ui": ui_n, + "title": self.title, + "openapi_url": openapi_url, + } + if scalar_js_url is not None: + docs_kwargs["scalar_js_url"] = scalar_js_url + if scalar_js_sri is not None: + docs_kwargs["scalar_js_sri"] = scalar_js_sri + if swagger_css_url is not None: + docs_kwargs["swagger_css_url"] = swagger_css_url + if swagger_css_sri is not None: + docs_kwargs["swagger_css_sri"] = swagger_css_sri + if swagger_js_url is not None: + docs_kwargs["swagger_js_url"] = swagger_js_url + if swagger_js_sri is not None: + docs_kwargs["swagger_js_sri"] = swagger_js_sri + + html = docs_html(**docs_kwargs) # type: ignore[arg-type] headers = {"content-type": "text/html; charset=utf-8"} def _docs() -> Response: diff --git a/oxyroute/docs_ui.py b/oxyroute/docs_ui.py index 949344d..e94789e 100644 --- a/oxyroute/docs_ui.py +++ b/oxyroute/docs_ui.py @@ -1,13 +1,39 @@ -"""Built-in OpenAPI docs UIs (Scalar / Swagger UI) loaded from CDN.""" +"""Built-in OpenAPI docs UIs (Scalar / Swagger UI) loaded from CDN with pinned versions and SRI.""" from __future__ import annotations from html import escape -__all__ = ["docs_html", "normalize_docs_ui"] +__all__ = [ + "SCALAR_JS_SRI", + "SCALAR_JS_URL", + "SCALAR_VERSION", + "SWAGGER_CSS_SRI", + "SWAGGER_CSS_URL", + "SWAGGER_JS_SRI", + "SWAGGER_JS_URL", + "SWAGGER_UI_VERSION", + "docs_html", + "normalize_docs_ui", +] _VALID = frozenset({"scalar", "swagger"}) +# Pinned versions and Subresource Integrity (SRI) hashes for public CDN assets +SCALAR_VERSION = "1.75.0" +SCALAR_JS_URL = f"https://cdn.jsdelivr.net/npm/@scalar/api-reference@{SCALAR_VERSION}/dist/browser/standalone.js" +SCALAR_JS_SRI = "sha384-xgelKlDcc+BYp0O+ZYzFesLnWCgGpEbXVVZWNQT6WyZYygN01tbEMygnjPXb5OPI" + +SWAGGER_UI_VERSION = "5.33.3" +SWAGGER_CSS_URL = ( + f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css" +) +SWAGGER_CSS_SRI = "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW" +SWAGGER_JS_URL = ( + f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js" +) +SWAGGER_JS_SRI = "sha384-h00llMzD1afyNow1pDmnZKlHsUZgp9KE9HXLFXF2CvbwsBY0+WBO4f75SfGwvM4K" + def normalize_docs_ui(ui: str | None) -> str | None: if ui is None: @@ -23,6 +49,12 @@ def docs_html( ui: str, title: str, openapi_url: str = "/openapi.json", + scalar_js_url: str = SCALAR_JS_URL, + scalar_js_sri: str | None = SCALAR_JS_SRI, + swagger_css_url: str = SWAGGER_CSS_URL, + swagger_css_sri: str | None = SWAGGER_CSS_SRI, + swagger_js_url: str = SWAGGER_JS_URL, + swagger_js_sri: str | None = SWAGGER_JS_SRI, ) -> str: """Return HTML for Scalar or Swagger UI pointing at ``openapi_url``.""" ui_n = normalize_docs_ui(ui) @@ -31,11 +63,31 @@ def docs_html( safe_title = escape(title) safe_url = escape(openapi_url, quote=True) if ui_n == "scalar": - return _scalar_html(safe_title, safe_url) - return _swagger_html(safe_title, safe_url) + return _scalar_html( + title=safe_title, + openapi_url=safe_url, + js_url=scalar_js_url, + js_sri=scalar_js_sri, + ) + return _swagger_html( + title=safe_title, + openapi_url=safe_url, + css_url=swagger_css_url, + css_sri=swagger_css_sri, + js_url=swagger_js_url, + js_sri=swagger_js_sri, + ) -def _scalar_html(title: str, openapi_url: str) -> str: +def _scalar_html( + title: str, + openapi_url: str, + js_url: str, + js_sri: str | None, +) -> str: + sri_attr = ( + f' integrity="{escape(js_sri, quote=True)}" crossorigin="anonymous"' if js_sri else "" + ) return f""" @@ -48,13 +100,26 @@ def _scalar_html(title: str, openapi_url: str) -> str: + src="{escape(js_url, quote=True)}"{sri_attr}> """ -def _swagger_html(title: str, openapi_url: str) -> str: +def _swagger_html( + title: str, + openapi_url: str, + css_url: str, + css_sri: str | None, + js_url: str, + js_sri: str | None, +) -> str: + css_sri_attr = ( + f' integrity="{escape(css_sri, quote=True)}" crossorigin="anonymous"' if css_sri else "" + ) + js_sri_attr = ( + f' integrity="{escape(js_sri, quote=True)}" crossorigin="anonymous"' if js_sri else "" + ) return f""" @@ -62,12 +127,12 @@ def _swagger_html(title: str, openapi_url: str) -> str: {title} — API docs + href="{escape(css_url, quote=True)}"{css_sri_attr}/>
- +