diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index 978ebd1c..f932e1ad 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -257,9 +257,11 @@ class WebDAVClient(BaseNextcloudClient): if href is None: continue - # Extract file/directory name from href + # Extract file/directory name from href. is required by + # RFC 3986 to be percent-encoded, so non-ASCII names arrive + # encoded — decode before exposing to callers (issue #776). href_text = href.text or "" - name = href_text.rstrip("/").split("/")[-1] + name = unquote(href_text.rstrip("/").split("/")[-1]) if not name: continue @@ -767,8 +769,10 @@ class WebDAVClient(BaseNextcloudClient): if href is None: continue - # Extract file/directory path from href - href_text = href.text or "" + # Extract file/directory path from href. is required by + # RFC 3986 to be percent-encoded, so non-ASCII paths arrive + # encoded — decode before exposing to callers (issue #776). + href_text = unquote(href.text or "") # Remove the /remote.php/dav/files/username/ prefix to get relative path path_parts = href_text.split("/files/") if len(path_parts) > 1: diff --git a/tests/unit/client/test_webdav.py b/tests/unit/client/test_webdav.py index 03b0b728..c02144e0 100644 --- a/tests/unit/client/test_webdav.py +++ b/tests/unit/client/test_webdav.py @@ -414,3 +414,99 @@ async def test_get_files_by_tag_detects_directories(mocker): call_args = mock_http_client.request.call_args assert "" in call_args.kwargs["content"] assert "42" in call_args.kwargs["content"] + + +@pytest.mark.unit +async def test_list_directory_decodes_non_ascii_names(mocker): + """list_directory must percent-decode for non-ASCII filenames (issue #776). + + RFC 3986 requires to be percent-encoded, so a Chinese-named directory + arrives as e.g. "%e5%ad%a6%e7%94%9f%e9%82%ae%e7%ae%b1". The MCP response should + expose the decoded "学生邮箱", not the encoded form. + """ + mock_http_client = AsyncMock() + client = WebDAVClient(mock_http_client, "testuser") + + # PROPFIND response with one Chinese-named subdirectory and one ASCII file. + # The first is the parent directory and is skipped by list_directory. + xml_content = b""" + + + /remote.php/dav/files/testuser/ + + + + + + + + /remote.php/dav/files/testuser/%e5%ad%a6%e7%94%9f%e9%82%ae%e7%ae%b1/ + + + \xe5\xad\xa6\xe7\x94\x9f\xe9\x82\xae\xe7\xae\xb1 + + + + + + /remote.php/dav/files/testuser/notes.txt + + + notes.txt + 10 + text/plain + + + + + """ + + mock_response = AsyncMock() + mock_response.content = xml_content + mock_response.raise_for_status = mocker.Mock() + mock_http_client.request = AsyncMock(return_value=mock_response) + + items = await client.list_directory("") + + by_name = {item["name"]: item for item in items} + assert "学生邮箱" in by_name, f"expected decoded Chinese name, got: {list(by_name)}" + assert by_name["学生邮箱"]["is_directory"] is True + assert by_name["学生邮箱"]["path"] == "学生邮箱" + + # ASCII entries must keep working. + assert "notes.txt" in by_name + assert by_name["notes.txt"]["is_directory"] is False + + +@pytest.mark.unit +def test_parse_search_response_decodes_non_ascii_paths(mocker): + """_parse_search_response must percent-decode for non-ASCII paths (issue #776). + + Affects find_by_name, find_by_type, list_favorites, and search_files: the `path` + and `href` fields would otherwise leak percent-encoded URL form to callers. + """ + mock_http_client = AsyncMock() + client = WebDAVClient(mock_http_client, "testuser") + + xml_content = b""" + + + /remote.php/dav/files/testuser/%e5%ad%a6%e7%94%9f%e9%82%ae%e7%ae%b1/report.pdf + + + report.pdf + application/pdf + 1024 + + + + + """ + + results = client._parse_search_response(xml_content, scope="") + + assert len(results) == 1 + assert results[0]["path"] == "学生邮箱/report.pdf" + assert results[0]["href"] == "/remote.php/dav/files/testuser/学生邮箱/report.pdf" + # name comes from , which is not URL-encoded; sanity-check it. + assert results[0]["name"] == "report.pdf"