Repository: litestar-org/litestar

Issue:
Bug: mounted app path interferes with regular paths
### Description

According to ["Mounting ASGI apps"](https://docs.litestar.dev/latest/usage/routing/overview.html#mounting-asgi-apps) documentation section, Litestar can mount ASGI apps in **sub-paths**. So it is expected that if ASGI app is mounted with `path='/magic'`, every route _starting with `/magic`_ will be handled by the ASGI app, and any other route will be handled by other handlers. However, it is not true.

Imagine this setup:

```python
@asgi("/magic", is_mount=True)
async def xxx(...):
    print('Mounted')
    ...

@get("/{number:int}/magic/")
async def yyy() -> str:
    print('Parametrized')

@get("/static/magic/")
async def zzz() -> str:
    print('Static')
```

Here's "expectations VS reality" table:
| Request path  | Expected output | Real output |
| -------------------- | ------------------------ | ------------------|
| `/magic`           | `Mounted`           | `Mounted`     |
|`/123/magic/`   | `Parametrized`   | `Mounted`     |
|`/static/magic/`| `Static`                  | `Static`    |
|`/non-existent/magic/` | 404 error | `Mounted` |

## Why this happens?

`litestar/_asgi/routing_trie/traversal.py:parse_path_to_route` method has [this line](https://github.com/litestar-org/litestar/blob/main/litestar/_asgi/routing_trie/traversal.py#L139):
```
if mount_paths_regex and (match := mount_paths_regex.search(path)):
```
So instead of **matching** `/magic` to `path`, `re.search` is used which searches for occurrence of `/magic` anywhere in `path`, thus resulting in "false positives" for strings such as `/123/magic/`, `/non-existent/magic/` and `/non-existent/magic/something`.

## Possible solution

This cannot be solved by simply using regex:
```python
@asgi("^/magic", is_mount=True)
```
since `mount_paths_regex` becomes `re.compile('/^/magic')`, so it not only doesn't solve the problem, but the `/magic` endpoint itself stops working.

I believe it may be solved by replacing `mount_paths_regex.search(path)` with `mount_paths_regex.match(path)` - I did manual tests and it solved the problem completely, but ofc such a change requires tests to ensure nothing else is broken.

I am ready to create a full-fledged pull request with tests once the issue is approved :)

### URL to code causing the issue

_No response_

### MCVE

```python
from typing import Any

from litestar import Litestar, asgi, get
from litestar.response.base import ASGIResponse


@asgi("/magic", is_mount=True)
async def mounted_handler(scope: Any, receive: Any, send: Any) -> None:
    body = 'mounted!'
    response = ASGIResponse(body=body.encode("utf-8"))
    await response(scope, receive, send)


@get("/{number:int}/magic/")
async def parametrized_handler() -> str:
    return 'parametrized'


@get("/static/magic/")
async def static_handler() -> str:
    return 'static'


app = Litestar(route_handlers=[
    mounted_handler,
    parametrized_handler,
    static_handler,
])
```


### Steps to reproduce

```bash
1. Use the source code from MCVE to run Litestar app
2. Run curl to see wrong handler invoked for parametrized path:
  > curl http://127.0.0.1:8000/123/magic
  mounted!     
3. Run curl to see wrong handler invoked for non-existent path:
  > curl http://127.0.0.1:8000/whatever/magic
  mounted!
```


### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.8.3

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)


Discussion:
Issue confirmed @0xE111 - I've assigned the issue to you. Thanks!
@all-contributors add @0xE111 for bug
@peterschutt 

I've put up [a pull request](https://github.com/litestar-org/litestar/pull/3503) to add @0xE111! :tada:
Thanks @0xE111 for the very detailed report!

> I believe it may be solved by replacing `mount_paths_regex.search(path)` with `mount_paths_regex.match(path)` - I did manual tests and it solved the problem completely, but ofc such a change requires tests to ensure nothing else is broken.

This sounds like the correct fix here to get the intended behaviour. A PR would be very welcome :)

Resolving patch:
diff --git a/litestar/_asgi/routing_trie/traversal.py b/litestar/_asgi/routing_trie/traversal.py
--- a/litestar/_asgi/routing_trie/traversal.py
+++ b/litestar/_asgi/routing_trie/traversal.py
@@ -136,8 +136,8 @@ def parse_path_to_route(
             asgi_app, handler = parse_node_handlers(node=root_node.children[path], method=method)
             return asgi_app, handler, path, {}
 
-        if mount_paths_regex and (match := mount_paths_regex.search(path)):
-            mount_path = path[match.start() : match.end()]
+        if mount_paths_regex and (match := mount_paths_regex.match(path)):
+            mount_path = path[: match.end()]
             mount_node = mount_routes[mount_path]
             remaining_path = path[match.end() :]
             # since we allow regular handlers under static paths, we must validate that the request does not match


Test patch:
diff --git a/tests/unit/test_asgi/test_routing_trie/test_traversal.py b/tests/unit/test_asgi/test_routing_trie/test_traversal.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_asgi/test_routing_trie/test_traversal.py
@@ -0,0 +1,93 @@
+from typing import Any
+
+from litestar import Router, asgi, get
+from litestar.response.base import ASGIResponse
+from litestar.status_codes import HTTP_404_NOT_FOUND
+from litestar.testing import create_test_client
+
+
+def test_parse_path_to_route_mounted_app_path_root() -> None:
+    # test that paths are correctly dispatched to handlers when mounting an app
+    # and other handlers to root path /
+
+    @asgi("/foobar", is_mount=True)
+    async def mounted_handler(scope: Any, receive: Any, send: Any) -> None:
+        response = ASGIResponse(body="mounted")
+        await response(scope, receive, send)
+
+    @get("/{number:int}/foobar/")
+    async def parametrized_handler() -> str:
+        return "parametrized"
+
+    @get("/static/foobar/")
+    async def static_handler() -> str:
+        return "static"
+
+    with create_test_client(
+        [
+            mounted_handler,
+            parametrized_handler,
+            static_handler,
+        ]
+    ) as client:
+        response = client.get("/foobar")
+        assert response.text == "mounted"
+
+        response = client.get("/foobar/123/")
+        assert response.text == "mounted"
+
+        response = client.get("/123/foobar/")
+        assert response.text == "parametrized"
+
+        response = client.get("/static/foobar/")
+        assert response.text == "static"
+
+        response = client.get("/unknown/foobar/")
+        assert response.status_code == HTTP_404_NOT_FOUND
+
+
+def test_parse_path_to_route_mounted_app_path_router() -> None:
+    # test that paths are correctly dispatched to handlers when mounting an app
+    # and other handlers inside subrouter
+
+    @asgi("/foobar", is_mount=True)
+    async def mounted_handler(scope: Any, receive: Any, send: Any) -> None:
+        response = ASGIResponse(body="mounted")
+        await response(scope, receive, send)
+
+    @get("/{number:int}/foobar/")
+    async def parametrized_handler() -> str:
+        return "parametrized"
+
+    @get("/static/foobar/")
+    async def static_handler() -> str:
+        return "static"
+
+    sub_router = Router(
+        path="/sub",
+        route_handlers=[
+            mounted_handler,
+            parametrized_handler,
+            static_handler,
+        ],
+    )
+    base_router = Router(path="/base", route_handlers=[sub_router])
+
+    with create_test_client([base_router]) as client:
+        response = client.get("/foobar")
+        assert response.status_code == HTTP_404_NOT_FOUND
+
+        response = client.get("/base/sub/foobar")
+        assert response.text == "mounted"
+
+        response = client.get("/base/sub/foobar/123/")
+        assert response.text == "mounted"
+
+        response = client.get("/base/sub/123/foobar/")
+        assert response.text == "parametrized"
+
+        response = client.get("/base/sub/static/foobar/")
+        assert response.text == "static"
+
+        response = client.get("/base/sub/unknown/foobar/")
+        assert response.status_code == HTTP_404_NOT_FOUND