diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index 78ce64b2..19b654d4 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -54,6 +54,11 @@ class WebDAVClient(BaseNextcloudClient): Percent-encodes the caller-supplied portion (see ``_encode_dav_path``) so names with ``#``, commas, or spaces don't truncate/404; the base ``/remote.php/dav/files/`` segment is left as-is. + + Precondition: ``path`` is a **decoded** path (the convention everywhere + in this client — PROPFIND/REPORT hrefs are ``unquote``d before storage, + and MCP-tool inputs are raw). It is encoded exactly once, so passing an + already-encoded path would double-encode it (``%20`` → ``%2520``). """ return f"{self._get_webdav_base_path()}/{_encode_dav_path(path.lstrip('/'))}" diff --git a/tests/unit/client/test_webdav.py b/tests/unit/client/test_webdav.py index b1268f2f..cf8a3871 100644 --- a/tests/unit/client/test_webdav.py +++ b/tests/unit/client/test_webdav.py @@ -517,6 +517,29 @@ def _request_url(mock_http_client) -> str: return mock_http_client.request.call_args[0][1] +@pytest.mark.unit +@pytest.mark.parametrize( + "path, expected", + [ + ("", "/remote.php/dav/files/testuser/"), + ("/Documents/notes.txt", "/remote.php/dav/files/testuser/Documents/notes.txt"), + ("Documents/notes.txt", "/remote.php/dav/files/testuser/Documents/notes.txt"), + ("a/b #1.pdf", "/remote.php/dav/files/testuser/a/b%20%231.pdf"), + ("law/x, y z.pdf", "/remote.php/dav/files/testuser/law/x%2C%20y%20%20z.pdf"), + ( + "学生邮箱/r.pdf", + "/remote.php/dav/files/testuser/%E5%AD%A6%E7%94%9F%E9%82%AE%E7%AE%B1/r.pdf", + ), + ], +) +def test_webdav_path_encoding(path, expected): + """_webdav_path encodes the decoded caller path once, preserving '/', and + strips a leading slash. Every caller-path builder routes through this, so + it is the single source of truth for their encoding.""" + client = WebDAVClient(AsyncMock(), "testuser") + assert client._webdav_path(path) == expected + + @pytest.mark.unit async def test_read_file_encodes_special_chars(mocker): """read_file must percent-encode '#', commas, and spaces in the path (card 309).