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()