Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/rpcclient/rpcclient/core/subsystems/fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@


if TYPE_CHECKING:
from construct import Construct, ParsedType
from construct import Construct
from construct.core import ParsedType

from rpcclient.core.symbol import Symbol

Expand Down Expand Up @@ -148,7 +149,7 @@ async def _deallocate(self) -> None:

async def seek(self, offset: int, whence: int) -> int:
"""Seek the remote file descriptor and return the resulting offset."""
err = (await self._client.symbols.lseek(self.fd, offset, whence)).c_int32
err = (await self._client.symbols.lseek(self.fd, offset, whence)).c_int64
if err < 0:
await self._client.raise_errno_exception(f"failed to lseek fd: {self.fd}")
return err
Expand Down
50 changes: 41 additions & 9 deletions src/rpcclient/rpcclient/core/subsystems/webdav.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import asyncio
import logging
import os
import posixpath
from stat import S_ISDIR, S_ISREG
from typing import TYPE_CHECKING, Any
Expand All @@ -26,7 +27,7 @@
)
from asgi_webdav.helpers import generate_etag, guess_type
from asgi_webdav.property import DAVProperty, DAVPropertyBasicData
from asgi_webdav.provider.common import DAVProvider, DAVProviderFeature
from asgi_webdav.provider.common import DAVProvider, DAVProviderFeature, get_response_content_range
from asgi_webdav.request import DAVRequest
from asgi_webdav.server import DAVApp
from asgi_webdav.web_dav import PrefixProviderInfo
Expand Down Expand Up @@ -68,7 +69,7 @@ class RpcFsProvider(DAVProvider):
"""A WebDAV provider whose backing store is a remote target's filesystem."""

type = "rpcfs"
feature = DAVProviderFeature(content_range=False, home_dir=False)
feature = DAVProviderFeature(content_range=True, home_dir=False)

def __init__(self, client: Any, root: str, config: Config, prefix: DAVPath, read_only: bool) -> None:
super().__init__(
Expand Down Expand Up @@ -196,7 +197,25 @@ async def _do_get(
return 403, None, None, None

dav_property = await self._create_dav_property_obj(request, request.src_path, stat_result)
return 200, dav_property.basic_data, self._body_generator(remote), None

if not request.ranges:
return 200, dav_property.basic_data, self._body_generator(remote), None

# a Range request: macOS Finder / webdavfs reads large files as a series of byte ranges.
# Answering those with 200 + the whole file makes the client write the full body at the
# range's offset, corrupting the result. Serve the requested bytes as 206 Partial Content.
content_range = get_response_content_range(
request_ranges=request.ranges,
file_size=dav_property.basic_data.content_length,
)
if content_range is None:
return 200, dav_property.basic_data, self._body_generator(remote), None
if request.if_range and not request.if_range.match(
etag=dav_property.basic_data.etag,
last_modified=dav_property.basic_data.last_modified.http_date,
):
return 416, dav_property.basic_data, None, content_range
return 206, dav_property.basic_data, self._body_generator(remote, content_range), content_range

async def _do_head(self, request: DAVRequest) -> tuple[int, DAVPropertyBasicData | None]:
if _is_apple_metadata(request.dist_src_path):
Expand All @@ -211,13 +230,26 @@ async def _do_head(self, request: DAVRequest) -> tuple[int, DAVPropertyBasicData
dav_property = await self._create_dav_property_obj(request, request.src_path, stat_result)
return 200, dav_property.basic_data

async def _body_generator(self, remote: RemotePath) -> DAVResponseBodyGenerator:
async def _body_generator(
self, remote: RemotePath, content_range: DAVResponseContentRange | None = None
) -> DAVResponseBodyGenerator:
async with await self._client.fs.open(str(remote), "r") as f:
more_body = True
while more_body:
data = await f.read(RESPONSE_DATA_BLOCK_SIZE)
more_body = len(data) == RESPONSE_DATA_BLOCK_SIZE
yield data, more_body
if content_range is None:
more_body = True
while more_body:
data = await f.read(RESPONSE_DATA_BLOCK_SIZE)
more_body = len(data) == RESPONSE_DATA_BLOCK_SIZE
yield data, more_body
return

await f.seek(content_range.content_start, os.SEEK_SET)
remaining = content_range.content_end - content_range.content_start + 1
while remaining > 0:
data = await f.read(min(remaining, RESPONSE_DATA_BLOCK_SIZE))
if not data:
break
remaining -= len(data)
yield data, remaining > 0

async def _do_put(self, request: DAVRequest) -> int:
if _is_apple_metadata(request.dist_src_path):
Expand Down
3 changes: 2 additions & 1 deletion src/rpcclient/rpcclient/core/symbol.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@


if TYPE_CHECKING:
from construct import Construct, Container, ParsedType
from construct import Construct, Container
from construct.core import ParsedType

from rpcclient.core.client import CoreClient, RemoteCallArg

Expand Down
35 changes: 35 additions & 0 deletions src/rpcclient/tests/test_webdav.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,41 @@ async def test_get_serves_existing_file(client: Client, tmp_path) -> None:
await server.stop()


@pytest.mark.asyncio
async def test_get_honors_range_request(client: Client, tmp_path) -> None:
# macOS Finder / webdavfs reads large files as a series of byte ranges. Answering a Range
# request with 200 + the whole file makes the client write the full body at the range's
# offset, corrupting the result. The server must reply 206 with exactly the requested bytes.
payload = bytes((i * 131 + 7) % 256 for i in range(4096)) * 64 # 256 KiB, spans many blocks
target = tmp_path / "big.bin"
await client.fs.write_file(target, payload)

server = await client.webdav.serve(str(tmp_path), port=0)
try:
async with httpx.AsyncClient() as http:
head = await http.head(f"{server.url}big.bin")
assert head.headers.get("accept-ranges") == "bytes"

start, end = 100_000, 200_000
response = await http.get(f"{server.url}big.bin", headers={"Range": f"bytes={start}-{end}"})
assert response.status_code == 206
assert response.headers["content-range"] == f"bytes {start}-{end}/{len(payload)}"
assert response.content == payload[start : end + 1]

# reassembling sequential ranges must reproduce the file byte-for-byte
buf = bytearray()
off = 0
while off < len(payload):
stop = min(off + 40_000 - 1, len(payload) - 1)
part = await http.get(f"{server.url}big.bin", headers={"Range": f"bytes={off}-{stop}"})
assert part.status_code == 206
buf += part.content
off = stop + 1
assert bytes(buf) == payload
finally:
await server.stop()


@pytest.mark.asyncio
async def test_put_when_fs_open_fails_returns_error_not_crash(client: Client, tmp_path) -> None:
# a regular file used as a path component makes the remote open() fail (ENOTDIR),
Expand Down
Loading