From 5999e2e677a3f03d8ea9a245c53c0ce96e50fa08 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:53:22 +0000 Subject: [PATCH 1/3] Fix WebDAV PROPPATCH support and handle invalid client paths safely - Add PROPPATCH HTTP method handling in asgidav returning 207 Multi-Status - Safely handle invalid client names and path parsing in WebDAV _get_member to return 404 instead of raising KeyError Co-authored-by: VulcanoSoftware <113239901+VulcanoSoftware@users.noreply.github.com> --- asgidav/app.py | 16 ++++++++++++++- asgidav/reqres.py | 33 +++++++++++++++++++++++++++++++ dcfs/app/webdav/__init__.py | 9 ++++++++- tests/test_asgidav/test_app.py | 29 +++++++++++++++++++++++++++ tests/test_asgidav/test_reqres.py | 24 +++++++++++++++++++++- 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/asgidav/app.py b/asgidav/app.py index a76221a..50a00d4 100644 --- a/asgidav/app.py +++ b/asgidav/app.py @@ -12,7 +12,7 @@ from .folder import Folder from .member import Member -from .reqres import PropfindRequest, propfind +from .reqres import PropfindRequest, propfind, proppatch from .resource import Resource logger = logging.getLogger(__name__) @@ -44,6 +44,7 @@ def extract_path_from_destination(destination: str) -> str: "DELETE", "OPTIONS", "PROPFIND", + "PROPPATCH", "COPY", "MOVE", "MKCOL", @@ -105,6 +106,19 @@ async def handle_propfind(request: Request, path: str): ) return NOT_FOUND + @app.api_route("/{path:path}", methods=["PROPPATCH"]) + async def handle_proppatch(request: Request, path: str): + if member := await get_member(path): + resp = await proppatch(member, request, base_path) + return Response( + resp, + status_code=HTTPStatus.MULTI_STATUS, + media_type="application/xml; charset=utf-8", + headers=common_headers + | {"Content-Type": "application/xml; charset=utf-8"}, + ) + return NOT_FOUND + @app.head("/{path:path}") async def head(request: Request, path: str): if member := await get_member(path): diff --git a/asgidav/reqres.py b/asgidav/reqres.py index cd9f26e..74efc5f 100644 --- a/asgidav/reqres.py +++ b/asgidav/reqres.py @@ -142,3 +142,36 @@ async def propfind( et.register_namespace("D", DAV_NS) return et.tostring(root, encoding="unicode") + + +async def proppatch( + member: Member, + request: Request, + base_path: str, +) -> str: + root = et.Element(_tag("multistatus"), nsmap=NS_MAP) + response_elem = et.SubElement(root, _tag("response")) + + href = et.SubElement(response_elem, _tag("href")) + href.text = quote(f"{base_path}{member.path}", safe="/") + + propstat_elem = et.SubElement(response_elem, _tag("propstat")) + prop_elem = et.SubElement(propstat_elem, _tag("prop")) + + try: + body = await request.body() + if body: + parsed = et.fromstring(body) + for child in parsed.xpath( + ".//*[local-name()='set' or local-name()='remove']/*[local-name()='prop']/*" + ): + if isinstance(child.tag, str): + et.SubElement(prop_elem, child.tag) + except Exception: + pass + + status = et.SubElement(propstat_elem, _tag("status")) + status.text = "HTTP/1.1 200 OK" + + et.register_namespace("D", DAV_NS) + return et.tostring(root, encoding="unicode") diff --git a/dcfs/app/webdav/__init__.py b/dcfs/app/webdav/__init__.py index 0027e48..df3f40e 100644 --- a/dcfs/app/webdav/__init__.py +++ b/dcfs/app/webdav/__init__.py @@ -20,7 +20,14 @@ async def _get_member(path: str, clients: Clients) -> Optional[Member]: folders[client_name] = folder return RootFolder(folders) - client_name, sub_path = split_global_path(path) + try: + client_name, sub_path = split_global_path(path) + except Exception: + return None + + if client_name not in clients or client_name not in gfc: + return None + cache = gfc[client_name] if not (root := cache.get("/")): diff --git a/tests/test_asgidav/test_app.py b/tests/test_asgidav/test_app.py index 2c7ab19..eff98ad 100644 --- a/tests/test_asgidav/test_app.py +++ b/tests/test_asgidav/test_app.py @@ -1,3 +1,4 @@ +import pytest from asgidav.app import extract_path_from_destination, split_path @@ -41,3 +42,31 @@ def test_extract_path_from_destination_encoded(self): path = "/webdav/path%20with%20spaces/file.txt" result = extract_path_from_destination(path) assert result == "/webdav/path with spaces/file.txt" + + +class TestAppEndpoints: + @pytest.mark.asyncio + async def test_proppatch_endpoint_found(self, mocker): + from fastapi.testclient import TestClient + from asgidav.app import create_app + from .common import MockResource + + mock_get_member = mocker.AsyncMock(return_value=MockResource("/test.txt")) + app = create_app(get_member=mock_get_member) + client = TestClient(app) + + response = client.request("PROPPATCH", "/test.txt") + assert response.status_code == 207 + assert "multistatus" in response.text + + @pytest.mark.asyncio + async def test_proppatch_endpoint_not_found(self, mocker): + from fastapi.testclient import TestClient + from asgidav.app import create_app + + mock_get_member = mocker.AsyncMock(return_value=None) + app = create_app(get_member=mock_get_member) + client = TestClient(app) + + response = client.request("PROPPATCH", "/nonexistent.txt") + assert response.status_code == 404 diff --git a/tests/test_asgidav/test_reqres.py b/tests/test_asgidav/test_reqres.py index 653dbc9..a60d64d 100644 --- a/tests/test_asgidav/test_reqres.py +++ b/tests/test_asgidav/test_reqres.py @@ -1,7 +1,7 @@ import pytest from fastapi import Request -from asgidav.reqres import PropfindRequest, _propfind_response, _propstat, propfind +from asgidav.reqres import PropfindRequest, _propfind_response, _propstat, propfind, proppatch from .common import MockFolder, MockResource @@ -159,3 +159,25 @@ async def test_propfind_empty_members(self): assert isinstance(result, str) assert "multistatus" in result + + @pytest.mark.asyncio + async def test_proppatch(self, mocker): + resource = MockResource("/test.txt") + mock_request = mocker.Mock(spec=Request) + mock_request.body = mocker.AsyncMock( + return_value=b""" + + + + Wed, 01 Sep 2026 13:39:00 GMT + + + """ + ) + + result = await proppatch(resource, mock_request, "/webdav") + + assert isinstance(result, str) + assert "multistatus" in result + assert "Win32LastModifiedTime" in result + assert "HTTP/1.1 200 OK" in result From 718434a242e227158272543e68b1ad7c027cc18c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:59:13 +0000 Subject: [PATCH 2/3] Fix WebDAV PROPPATCH support and handle invalid client paths safely - Add PROPPATCH HTTP method handling in asgidav returning 207 Multi-Status - Safely handle invalid client names and path parsing in WebDAV _get_member to return 404 instead of raising KeyError - Fix mypy typing errors in asgidav/reqres.py Co-authored-by: VulcanoSoftware <113239901+VulcanoSoftware@users.noreply.github.com> --- asgidav/reqres.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/asgidav/reqres.py b/asgidav/reqres.py index 74efc5f..8991d18 100644 --- a/asgidav/reqres.py +++ b/asgidav/reqres.py @@ -162,11 +162,13 @@ async def proppatch( body = await request.body() if body: parsed = et.fromstring(body) - for child in parsed.xpath( + nodes = parsed.xpath( ".//*[local-name()='set' or local-name()='remove']/*[local-name()='prop']/*" - ): - if isinstance(child.tag, str): - et.SubElement(prop_elem, child.tag) + ) + if isinstance(nodes, list): + for child in nodes: + if isinstance(child, Element) and isinstance(child.tag, str): + et.SubElement(prop_elem, child.tag) except Exception: pass From 91a35d6bc047d135128276011c5024359ba4360d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:06:10 +0000 Subject: [PATCH 3/3] Fix WebDAV PROPPATCH support and handle invalid client paths safely - Add PROPPATCH HTTP method handling in asgidav returning 207 Multi-Status - Safely handle invalid client names and path parsing in WebDAV _get_member to return 404 instead of raising KeyError - Fix ruff S110 lint rule in asgidav/reqres.py by catching specific XML syntax and value errors Co-authored-by: VulcanoSoftware <113239901+VulcanoSoftware@users.noreply.github.com> --- asgidav/reqres.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asgidav/reqres.py b/asgidav/reqres.py index 8991d18..ab4a739 100644 --- a/asgidav/reqres.py +++ b/asgidav/reqres.py @@ -169,7 +169,7 @@ async def proppatch( for child in nodes: if isinstance(child, Element) and isinstance(child.tag, str): et.SubElement(prop_elem, child.tag) - except Exception: + except (et.XMLSyntaxError, TypeError, ValueError): pass status = et.SubElement(propstat_elem, _tag("status"))