Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
25 changes: 25 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let mut out = HashSet::new();
let chars: Vec<char> = 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> {
jsonwebtoken::Algorithm::from_str(s).map_err(|_| {
pyo3::exceptions::PyValueError::new_err(
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ pub struct RouteExtra {
pub handler_param_names: Arc<HashSet<String>>,
pub body_param_name: String,
pub rate_limiter: Option<Arc<crate::rate_limit::RateLimiter>>,
/// 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<HashSet<String>>,
}

/// Compact 32-byte route entry fitting comfortably inside a single 64-byte L1D cache line.
Expand Down
21 changes: 21 additions & 0 deletions tests/test_path_param_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
16 changes: 16 additions & 0 deletions tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading