Skip to content

Commit 2c3ef7b

Browse files
committed
Support every ASGI framework's mount convention via root_path
HashedStatic.__call__ reads scope["root_path"] to determine how the framework scoped the request. Starlette and FastAPI set root_path but leave the full path intact; Litestar strips the mount prefix from the path entirely. Both styles now resolve to the same relative file path. Standalone raw ASGI usage without a framework falls back to self.prefix matching. Key design decisions: - prefix remains a URL-generation concern (used by url() only). Request routing in __call__ uses root_path, not prefix. - Path traversal protections (resolve + is_relative_to) sit downstream of the dispatch, guarding all three branches equally.
1 parent 751bbd3 commit 2c3ef7b

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

src/staticware/middleware.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,22 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
118118
return
119119

120120
request_path: str = scope["path"]
121-
if not request_path.startswith(self.prefix + "/"):
122-
await _send_text(send, 404, b"Not Found")
123-
return
121+
root_path: str = scope.get("root_path", "")
124122

125-
relative_path = request_path[len(self.prefix) + 1 :]
123+
if root_path:
124+
# Framework mount: use root_path to derive the local path.
125+
if request_path.startswith(root_path + "/"):
126+
# Starlette-style: path still includes the mount prefix.
127+
relative_path = request_path[len(root_path) + 1 :]
128+
else:
129+
# Litestar-style: framework already stripped the prefix.
130+
relative_path = request_path.lstrip("/")
131+
else:
132+
# Standalone raw ASGI: use self.prefix to find the local path.
133+
if not request_path.startswith(self.prefix + "/"):
134+
await _send_text(send, 404, b"Not Found")
135+
return
136+
relative_path = request_path[len(self.prefix) + 1 :]
126137

127138
# Hashed filename — serve with immutable caching
128139
original_path = self._reverse.get(relative_path)

tests/test_staticware.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,77 @@ async def bad_encoding_app(scope: dict, receive: Any, send: Any) -> None:
387387
resp = ResponseCollector()
388388
await app(make_scope("/"), receive, resp)
389389
assert resp.body == raw_body
390+
391+
392+
# ── HashedStatic: framework mount compatibility ───────────────────────
393+
394+
395+
def make_mount_scope(path: str, *, root_path: str = "") -> dict[str, Any]:
396+
"""Like make_scope but accepts root_path for framework-mount simulation."""
397+
return {"type": "http", "path": path, "root_path": root_path, "method": "GET"}
398+
399+
400+
async def test_serve_with_root_path_scope(
401+
static: HashedStatic, static_dir: Path
402+
) -> None:
403+
"""Starlette-style mount: root_path set, path still includes the prefix.
404+
405+
Starlette sets scope["root_path"] = "/static" and leaves
406+
scope["path"] = "/static/styles.css". The current code happens to
407+
pass because the prefix check still matches, but we need this test
408+
to lock in the expected behavior when root_path is present.
409+
"""
410+
resp = ResponseCollector()
411+
scope = make_mount_scope("/static/styles.css", root_path="/static")
412+
await static(scope, receive, resp)
413+
assert resp.status == 200
414+
assert resp.text == "body { color: red; }"
415+
416+
417+
async def test_serve_with_stripped_path(
418+
static: HashedStatic, static_dir: Path
419+
) -> None:
420+
"""Litestar-style mount: framework strips the prefix from scope["path"].
421+
422+
The sub-app sees scope["root_path"] = "/static" and
423+
scope["path"] = "/styles.css". The current code 404s because
424+
"/styles.css" does not start with "/static/".
425+
"""
426+
resp = ResponseCollector()
427+
scope = make_mount_scope("/styles.css", root_path="/static")
428+
await static(scope, receive, resp)
429+
assert resp.status == 200
430+
assert resp.text == "body { color: red; }"
431+
assert b"cache-control" not in resp.headers
432+
433+
434+
async def test_serve_hashed_with_stripped_path(static: HashedStatic) -> None:
435+
"""Litestar-style mount with a hashed filename request.
436+
437+
scope["root_path"] = "/static", scope["path"] = "/styles.<hash>.css".
438+
Should serve the file with immutable cache headers but will 404
439+
against current code.
440+
"""
441+
hashed_name = static.file_map["styles.css"]
442+
resp = ResponseCollector()
443+
scope = make_mount_scope(f"/{hashed_name}", root_path="/static")
444+
await static(scope, receive, resp)
445+
assert resp.status == 200
446+
assert resp.text == "body { color: red; }"
447+
assert resp.headers[b"cache-control"] == b"public, max-age=31536000, immutable"
448+
449+
450+
async def test_serve_with_mismatched_mount_and_prefix(static_dir: Path) -> None:
451+
"""Mount prefix differs from HashedStatic prefix.
452+
453+
HashedStatic is constructed with prefix="/assets" but the framework
454+
mounts it at /static, so root_path="/static" and
455+
path="/static/styles.css". The current code 404s because the prefix
456+
check looks for "/assets/" which does not match "/static/...".
457+
"""
458+
static = HashedStatic(static_dir, prefix="/assets")
459+
resp = ResponseCollector()
460+
scope = make_mount_scope("/static/styles.css", root_path="/static")
461+
await static(scope, receive, resp)
462+
assert resp.status == 200
463+
assert resp.text == "body { color: red; }"

0 commit comments

Comments
 (0)