From 12edb23e27bcb761c1bae9d9e805644cff415bb7 Mon Sep 17 00:00:00 2001 From: doronz88 Date: Tue, 18 Aug 2026 22:51:02 +0300 Subject: [PATCH 1/2] feat(rpcclient): add WebDAV bridge for mounting remote paths in Finder Serve any remote target path over a local async WebDAV server (ASGIWebDAV) running inside the rpcclient process. Each WebDAV request is translated into client.fs calls, so a WebDAV client (macOS Finder, Windows Explorer, GNOME Files, ...) can browse and edit the remote filesystem read-write as a mounted volume. Works for iOS/macOS/Linux targets since it only uses the fs subsystem; no server-side changes. - new `webdav` subsystem on CoreClient: `await p.webdav.serve(path, ...)` - `rpcclient HOSTNAME webdav [PATH] [--mount]` subcommand and standalone `rpcdav` - served path is a positional argument, defaulting to / - `--mount` mounts and reveals the volume using the host's native mechanism (mount_webdav on macOS, net use on Windows, gio on Linux), falling back to printing the URL when none is available; the mount point is named rpc--- so it is easy to spot in Finder - swallow Finder metadata writes (.DS_Store / AppleDouble ._*) so they never hit the remote and never error on read-only roots - refuse to open non-regular files (fifos/devices/sockets): opening them blocks the single serialized RPC channel and would wedge the whole mount - build parent paths via remote_path() rather than RemotePath.parent, which drops the bound client on Python < 3.12 - translate fs failures into clean WebDAV statuses instead of 500 tracebacks Adds asgiwebdav + uvicorn (and backports.zstd on Python < 3.14) as dependencies, and httpx to the test extra. --- src/rpcclient/pyproject.toml | 6 +- src/rpcclient/rpcclient/__main__.py | 171 ++++++++- src/rpcclient/rpcclient/core/client.py | 5 + .../rpcclient/core/subsystems/webdav.py | 361 ++++++++++++++++++ src/rpcclient/rpcclient/core/webdav_mount.py | 132 +++++++ src/rpcclient/tests/test_webdav.py | 237 ++++++++++++ 6 files changed, 899 insertions(+), 13 deletions(-) create mode 100644 src/rpcclient/rpcclient/core/subsystems/webdav.py create mode 100644 src/rpcclient/rpcclient/core/webdav_mount.py create mode 100644 src/rpcclient/tests/test_webdav.py diff --git a/src/rpcclient/pyproject.toml b/src/rpcclient/pyproject.toml index 82728137..d730a4f2 100644 --- a/src/rpcclient/pyproject.toml +++ b/src/rpcclient/pyproject.toml @@ -56,10 +56,13 @@ dependencies = [ "protobuf", "inquirer3", "ipython-smart-await>=0.3.0", + "asgiwebdav>=2.0.1", + "backports.zstd; python_version < '3.14'", + "uvicorn", ] [project.optional-dependencies] -test = ["pytest", "pytest-repeat", "pytest-asyncio"] +test = ["pytest", "pytest-repeat", "pytest-asyncio", "httpx"] docs = ["mkdocs-material>=9.5", "mkdocstrings[python]>=0.26", "mkdocs-llmstxt>=0.5"] dev = ["rpcclient[test,docs]"] @@ -70,6 +73,7 @@ dev = ["rpcclient[test,docs]"] [project.scripts] rpcclient = "rpcclient.__main__:rpcclient" rpclocal = "rpcclient.__main__:rpclocal" +rpcdav = "rpcclient.__main__:rpcdav" [tool.setuptools.packages.find] exclude = ["docs*", "tests*"] diff --git a/src/rpcclient/rpcclient/__main__.py b/src/rpcclient/rpcclient/__main__.py index 2ef976d7..fc5b0bed 100644 --- a/src/rpcclient/rpcclient/__main__.py +++ b/src/rpcclient/rpcclient/__main__.py @@ -1,3 +1,4 @@ +import asyncio import logging import click @@ -6,6 +7,12 @@ from rpcclient.client_manager import ClientManager from rpcclient.clients.darwin.client import DarwinClient from rpcclient.console.console import Console, disable_loggers +from rpcclient.core.webdav_mount import ( + mount_webdav_volume, + reveal_in_file_manager, + unmount_webdav_volume, + webdav_mount_supported, +) from rpcclient.transports import DEFAULT_PORT from rpcclient.utils import run_in_loop @@ -38,37 +45,177 @@ async def _connect() -> int: Console(manager).interactive(switch_cid=cid, startup_files=startup_files) -@click.command() +async def _connect_client( + manager: ClientManager, hostname: str, port: int, rebind_symbols: bool, load_all_libraries: bool +): + client = await manager.create(hostname=hostname, port=port) + if isinstance(client, DarwinClient): + if rebind_symbols: + await client.rebind_symbols() + if load_all_libraries: + await client.load_all_libraries() + return client + + +@click.group(invoke_without_command=True) @click.argument("hostname", required=False) @click.option("-p", "--port", type=click.INT, default=DEFAULT_PORT, help="TCP port to connect to") @click.option("-r", "--rebind-symbols", is_flag=True, help="reload all symbols upon connection") @click.option("-l", "--load-all-libraries", is_flag=True, help="load all libraries") @startup_files_option +@click.pass_context def rpcclient( - hostname: str | None, port: int, rebind_symbols: bool, load_all_libraries: bool, startup_files: tuple[str] + ctx: click.Context, + hostname: str | None, + port: int, + rebind_symbols: bool, + load_all_libraries: bool, + startup_files: tuple[str], ): """ Start the console. If HOSTNAME is provided, connect immediately. Otherwise, start without a connection. You can connect later from the console. + Provide a subcommand (e.g. `webdav`) to run it against HOSTNAME instead of the console. """ manager = ClientManager() + ctx.obj = { + "manager": manager, + "hostname": hostname, + "port": port, + "rebind_symbols": rebind_symbols, + "load_all_libraries": load_all_libraries, + } + + if ctx.invoked_subcommand is not None: + return + cid = None if hostname: + client = run_in_loop(_connect_client(manager, hostname, port, rebind_symbols, load_all_libraries)) + cid = client.id - async def _connect() -> int: - client = await manager.create(hostname=hostname, port=port) - if isinstance(client, DarwinClient): - if rebind_symbols: - await client.rebind_symbols() - if load_all_libraries: - await client.load_all_libraries() - return client.id + Console(manager).interactive(switch_cid=cid, startup_files=startup_files) - cid = run_in_loop(_connect()) - Console(manager).interactive(switch_cid=cid, startup_files=startup_files) +def _require_mount_tool(mount: bool) -> None: + """Fail early if ``--mount`` was requested but no WebDAV mount tool is available on this host.""" + if mount and not webdav_mount_supported(): + raise click.UsageError( + "--mount is unavailable: no supported WebDAV mount tool found " + "(mount_webdav on macOS, net on Windows, gio on Linux)" + ) + + +def _serve_webdav( + manager: ClientManager, + hostname: str, + port: int, + rebind_symbols: bool, + load_all_libraries: bool, + path: str, + mount: bool, + bind_host: str, + bind_port: int, + readonly: bool, +) -> None: + """Connect to HOSTNAME, serve PATH over WebDAV, optionally mount, and block until interrupted.""" + _require_mount_tool(mount) + + async def _setup(): + client = await _connect_client(manager, hostname, port, rebind_symbols, load_all_libraries) + server = await client.webdav.serve(path, host=bind_host, port=bind_port, readonly=readonly) + mounted = await mount_webdav_volume(server.url, label=f"rpc-{hostname}-{path}") if mount else None + return client, server, mounted + + client, server, mounted = run_in_loop(_setup()) + click.echo(f"WebDAV server serving {path!r} at {server.url}") + if mounted is not None: + run_in_loop(reveal_in_file_manager(mounted.reveal_target)) + click.echo(f"Mounted; revealed {mounted.reveal_target}") + else: + if mount: + click.echo("Automatic mount is unavailable on this host; open the URL below in your file manager.") + click.echo(f"Open this WebDAV URL in your file manager: {server.url}") + click.echo("Press Ctrl-C to stop.") + + try: + run_in_loop(asyncio.Event().wait()) + except KeyboardInterrupt: + pass + finally: + + async def _teardown() -> None: + if mounted is not None: + await unmount_webdav_volume(mounted) + await server.stop() + await client.close() + + run_in_loop(_teardown()) + click.echo("stopped.") + + +@rpcclient.command() +@click.argument("path", required=False, default="/") +@click.option("--mount", is_flag=True, help="mount the served path locally and reveal it in your file manager") +@click.option("--host", "bind_host", default="127.0.0.1", help="local interface to bind") +@click.option("--port", "bind_port", type=click.INT, default=0, help="local TCP port (0 picks a free port)") +@click.option("--readonly", is_flag=True, help="expose the path read-only") +@click.pass_context +def webdav(ctx: click.Context, path: str, mount: bool, bind_host: str, bind_port: int, readonly: bool) -> None: + """Serve a remote PATH (default: /) over WebDAV for local mounting.""" + obj = ctx.obj + if not obj["hostname"]: + raise click.UsageError("HOSTNAME is required: rpcclient HOSTNAME webdav [--mount]") + _serve_webdav( + obj["manager"], + obj["hostname"], + obj["port"], + obj["rebind_symbols"], + obj["load_all_libraries"], + path, + mount, + bind_host, + bind_port, + readonly, + ) + + +@click.command() +@click.argument("hostname") +@click.argument("path", required=False, default="/") +@click.option("-p", "--port", type=click.INT, default=DEFAULT_PORT, help="TCP port to connect to") +@click.option("-r", "--rebind-symbols", is_flag=True, help="reload all symbols upon connection") +@click.option("-l", "--load-all-libraries", is_flag=True, help="load all libraries") +@click.option("--mount", is_flag=True, help="mount the served path locally and reveal it in your file manager") +@click.option("--host", "bind_host", default="127.0.0.1", help="local interface to bind") +@click.option("--bind-port", "bind_port", type=click.INT, default=0, help="local TCP port (0 picks a free port)") +@click.option("--readonly", is_flag=True, help="expose the path read-only") +def rpcdav( + hostname: str, + path: str, + port: int, + rebind_symbols: bool, + load_all_libraries: bool, + mount: bool, + bind_host: str, + bind_port: int, + readonly: bool, +) -> None: + """Serve a remote HOSTNAME's PATH (default: /) over WebDAV for local mounting.""" + _serve_webdav( + ClientManager(), + hostname, + port, + rebind_symbols, + load_all_libraries, + path, + mount, + bind_host, + bind_port, + readonly, + ) if __name__ == "__main__": diff --git a/src/rpcclient/rpcclient/core/client.py b/src/rpcclient/rpcclient/core/client.py index a5af6015..0210258c 100644 --- a/src/rpcclient/rpcclient/core/client.py +++ b/src/rpcclient/rpcclient/core/client.py @@ -50,6 +50,7 @@ from rpcclient.core.subsystems.network import Network from rpcclient.core.subsystems.processes import Processes from rpcclient.core.subsystems.sysctl import Sysctl +from rpcclient.core.subsystems.webdav import WebDav from rpcclient.core.symbol import Symbol from rpcclient.core.symbols_jar import ( LazySymbol, @@ -226,6 +227,10 @@ def lief(self) -> Lief[Self]: def sysctl(self) -> Sysctl[Self]: return Sysctl(self) + @subsystem + def webdav(self) -> WebDav[Self]: + return WebDav(self) + async def info(self) -> None: """print information about the current target""" uname = await self.get_uname() diff --git a/src/rpcclient/rpcclient/core/subsystems/webdav.py b/src/rpcclient/rpcclient/core/subsystems/webdav.py new file mode 100644 index 00000000..6b387cba --- /dev/null +++ b/src/rpcclient/rpcclient/core/subsystems/webdav.py @@ -0,0 +1,361 @@ +"""Bridge a remote target's filesystem to a local WebDAV server. + +Runs an async WebDAV server (ASGIWebDAV) inside the rpcclient process. Every +WebDAV request is translated into ``client.fs`` calls against the target, so a +local WebDAV client (e.g. macOS Finder) can browse and edit the remote path as +if it were a mounted volume. +""" + +from __future__ import annotations + +import asyncio +import logging +import posixpath +from stat import S_ISDIR, S_ISREG +from typing import TYPE_CHECKING, Any + +import uvicorn +from asgi_webdav.config import Config, generate_config_from_dict, reinit_global_config +from asgi_webdav.constants import ( + RESPONSE_DATA_BLOCK_SIZE, + DAVDepth, + DAVPath, + DAVResponseBodyGenerator, + DAVResponseContentRange, + DAVTime, +) +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.request import DAVRequest +from asgi_webdav.server import DAVApp +from asgi_webdav.web_dav import PrefixProviderInfo + +from rpcclient.core._types import ClientBound, ClientT_co +from rpcclient.exceptions import RpcClientException + + +if TYPE_CHECKING: + from rpcclient.core.subsystems.fs import RemotePath + + +_APPLE_METADATA_NAMES = frozenset({ + ".DS_Store", + ".localized", + ".hidden", + ".Trashes", + ".apdisk", + ".metadata_never_index", + ".VolumeIcon.icns", +}) + + +def _is_apple_metadata(path: DAVPath) -> bool: + """Whether a WebDAV path is macOS Finder metadata (``.DS_Store`` / AppleDouble ``._*`` / friends).""" + name = path.name + return name.startswith("._") or name in _APPLE_METADATA_NAMES + + +async def _drain(request: DAVRequest) -> None: + """Consume and discard a request body.""" + more_body = True + while more_body: + event = await request.receive() + more_body = event.get("more_body") + + +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) + + def __init__(self, client: Any, root: str, config: Config, prefix: DAVPath, read_only: bool) -> None: + super().__init__( + config=config, + prefix=prefix, + uri=f"rpcfs://{root}", + home_dir=False, + read_only=read_only, + ignore_property_extra=True, + ) + self._client = client + self._root = root.rstrip("/") or "/" + + def __repr__(self) -> str: + return f"rpcfs://{self._root}" + + def _remote(self, path: DAVPath) -> RemotePath: + """Return the remote path for a WebDAV path under the served root.""" + joined = self._root + for part in path.parts: + joined = joined.rstrip("/") + "/" + part + return self._client.fs.remote_path(joined) + + def _remote_parent(self, remote: RemotePath) -> RemotePath: + """Return the parent as a fresh ``remote_path``. + + ``RemotePath.parent`` drops the bound client on Python < 3.12 (pathlib rebuilds it via + ``_from_parsed_parts``, bypassing ``__init__``), so build the parent explicitly instead. + """ + return self._client.fs.remote_path(posixpath.dirname(str(remote).rstrip("/"))) + + async def _get_res_etag(self, request: DAVRequest) -> str: + stat_result = await self._remote(request.dist_src_path).stat() + return generate_etag(stat_result.st_size, stat_result.st_mtime) + + async def _create_dav_property_obj(self, request: DAVRequest, href_path: DAVPath, stat_result: Any) -> DAVProperty: + is_collection = S_ISDIR(stat_result.st_mode) + if is_collection: + basic_data = DAVPropertyBasicData( + is_collection=is_collection, + display_name=href_path.name, + creation_date=DAVTime(stat_result.st_ctime), + last_modified=DAVTime(stat_result.st_mtime), + ) + else: + content_type, content_encoding = guess_type(self.config, href_path.name) + basic_data = DAVPropertyBasicData( + is_collection=is_collection, + display_name=href_path.name, + creation_date=DAVTime(stat_result.st_ctime), + last_modified=DAVTime(stat_result.st_mtime), + content_type="" if content_type is None else content_type, + content_charset=None, + content_length=stat_result.st_size, + content_encoding=content_encoding, + ) + return DAVProperty(href_path=href_path, is_collection=is_collection, basic_data=basic_data) + + async def _get_dav_property_d0(self, request: DAVRequest, href_path: DAVPath) -> DAVProperty: + stat_result = await self._remote(href_path).stat() + return await self._create_dav_property_obj(request, href_path, stat_result) + + async def _do_propfind(self, request: DAVRequest) -> dict[DAVPath, DAVProperty]: + dav_properties: dict[DAVPath, DAVProperty] = {} + base = self._remote(request.dist_src_path) + if not await base.exists(): + return dav_properties + + base_stat = await base.stat() + dav_properties[request.src_path] = await self._create_dav_property_obj(request, request.src_path, base_stat) + + if request.depth != DAVDepth.ZERO and S_ISDIR(base_stat.st_mode): + await self._propfind_children( + dav_properties, request, request.src_path, infinity=request.depth == DAVDepth.INFINITY + ) + return dav_properties + + async def _propfind_children( + self, + dav_properties: dict[DAVPath, DAVProperty], + request: DAVRequest, + href_base: DAVPath, + infinity: bool, + depth_limit: int = 99, + ) -> None: + sub_dir_names: list[str] = [] + for name in await self._client.fs.listdir(str(self._remote(href_base))): + href_path = href_base.add_child(name) + if _is_apple_metadata(href_path): + continue + try: + stat_result = await self._remote(href_path).stat() + except Exception: + continue + dav_properties[href_path] = await self._create_dav_property_obj(request, href_path, stat_result) + if S_ISDIR(stat_result.st_mode) and infinity: + sub_dir_names.append(name) + + if not infinity or depth_limit <= 0: + return + for name in sub_dir_names: + await self._propfind_children(dav_properties, request, href_base.add_child(name), infinity, depth_limit - 1) + + async def _do_get( + self, request: DAVRequest + ) -> tuple[ + int, + DAVPropertyBasicData | None, + DAVResponseBodyGenerator | None, + DAVResponseContentRange | None, + ]: + if _is_apple_metadata(request.dist_src_path): + return 404, None, None, None + remote = self._remote(request.dist_src_path) + try: + stat_result = await remote.stat() + except RpcClientException: + return 404, None, None, None + + if S_ISDIR(stat_result.st_mode): + dav_property = await self._create_dav_property_obj(request, request.src_path, stat_result) + return 200, dav_property.basic_data, None, None + if not S_ISREG(stat_result.st_mode): + # refuse fifos / devices / sockets: opening them blocks the RPC channel and wedges the mount + 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 + + async def _do_head(self, request: DAVRequest) -> tuple[int, DAVPropertyBasicData | None]: + if _is_apple_metadata(request.dist_src_path): + return 404, None + remote = self._remote(request.dist_src_path) + try: + stat_result = await remote.stat() + except RpcClientException: + return 404, None + if not (S_ISDIR(stat_result.st_mode) or S_ISREG(stat_result.st_mode)): + return 403, None + 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 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 + + async def _do_put(self, request: DAVRequest) -> int: + if _is_apple_metadata(request.dist_src_path): + # swallow Finder metadata writes: report success without touching the remote target + await _drain(request) + return 201 + remote = self._remote(request.dist_src_path) + try: + stat_result = await remote.stat() + except RpcClientException: + stat_result = None + if stat_result is not None and not S_ISREG(stat_result.st_mode): + # target exists as a directory / fifo / device: refuse (opening it may block or is invalid) + return 405 + if not await self._remote_parent(remote).exists(): + return 409 + + try: + async with await self._client.fs.open(str(remote), "w") as f: + more_body = True + while more_body: + event = await request.receive() + more_body = event.get("more_body") + data = event.get("body", b"") + if data: + await f.write(data) + except RpcClientException: + return 403 + return 201 + + async def _do_delete(self, request: DAVRequest) -> int: + if _is_apple_metadata(request.dist_src_path): + return 204 + remote = self._remote(request.dist_src_path) + if not await remote.exists(): + return 404 + try: + await remote.remove(recursive=True, force=True) + except RpcClientException: + return 403 + return 204 + + async def _do_mkcol(self, request: DAVRequest) -> int: + remote = self._remote(request.dist_src_path) + if await remote.exists(): + return 405 + if not await self._remote_parent(remote).exists(): + return 409 + try: + await remote.mkdir() + except RpcClientException: + return 403 + return 201 + + async def _do_move(self, request: DAVRequest) -> int: + src = self._remote(request.dist_src_path) + dst = self._remote(request.dist_dst_path) + if not await src.exists(): + return 403 + if not await self._remote_parent(dst).exists(): + return 409 + dst_exists = await dst.exists() + if not request.overwrite and dst_exists: + return 412 + try: + if dst_exists: + await dst.remove(recursive=True, force=True) + await self._client.fs.rename(str(src), str(dst)) + except RpcClientException: + return 403 + return 204 if request.overwrite else 201 + + +class WebDavServer: + """Handle for a running WebDAV server.""" + + def __init__(self, uvicorn_server: Any, task: Any, host: str, port: int) -> None: + self._server = uvicorn_server + self._task = task + self.host = host + self.port = port + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/" + + async def stop(self) -> None: + self._server.should_exit = True + await self._task + + +class WebDav(ClientBound[ClientT_co]): + """Serve a remote path over WebDAV for local mounting.""" + + def __init__(self, client: ClientT_co) -> None: + self._client = client + + async def serve(self, path: str, *, host: str = "127.0.0.1", port: int = 0, readonly: bool = False) -> WebDavServer: + """Start a WebDAV server exposing the remote ``path``. + + :param path: remote directory to serve. + :param host: local interface to bind (default: loopback). + :param port: local TCP port; 0 picks a free port. + :param readonly: expose the path read-only. + :return: a running-server handle with ``url`` and ``stop()``. + """ + for name in ("asgi_webdav", "uvicorn", "uvicorn.error", "uvicorn.access"): + logging.getLogger(name).setLevel(logging.WARNING) + + config = generate_config_from_dict({ + "account_mapping": [{"username": "anonymous", "password": "", "permissions": ["+"]}], + "anonymous": { + "enable": True, + "user": {"username": "anonymous", "password": "", "permissions": ["+"]}, + "allow_missing_auth_header": True, + }, + "provider_mapping": [], + "logging": {"enable": False}, + }) + reinit_global_config(config) + + app = DAVApp(config) + provider = RpcFsProvider(client=self._client, root=path, config=config, prefix=DAVPath("/"), read_only=readonly) + app.web_dav.prefix_provider_mapping = [ + PrefixProviderInfo( + prefix=DAVPath("/"), + prefix_weight=1, + provider=provider, + home_dir=False, + read_only=readonly, + ignore_property_extra=True, + ) + ] + + uv_config = uvicorn.Config(app, host=host, port=port, log_level="warning", lifespan="off") + server = uvicorn.Server(uv_config) + task = asyncio.ensure_future(server.serve()) + while not server.started: + await asyncio.sleep(0.02) + bound_port = server.servers[0].sockets[0].getsockname()[1] + return WebDavServer(server, task, host, bound_port) diff --git a/src/rpcclient/rpcclient/core/webdav_mount.py b/src/rpcclient/rpcclient/core/webdav_mount.py new file mode 100644 index 00000000..de791a02 --- /dev/null +++ b/src/rpcclient/rpcclient/core/webdav_mount.py @@ -0,0 +1,132 @@ +"""Cross-platform helpers for mounting a WebDAV URL locally and revealing it. + +Per host: +- macOS: ``mount_webdav`` mounts to a temp dir; ``open`` reveals it. +- Windows: ``net use`` maps a drive letter; ``explorer`` reveals it. +- Linux: ``gio mount`` mounts the ``dav(s)://`` URL; ``xdg-open`` reveals it. + +Everything is best-effort: if the host's tool is missing or the mount fails, the caller +falls back to printing the URL for the user to open manually. +""" + +from __future__ import annotations + +import asyncio +import re +import shutil +import sys +import tempfile +from contextlib import suppress +from dataclasses import dataclass + + +@dataclass +class MountedVolume: + """A mounted WebDAV volume: what to reveal, and how to unmount it.""" + + reveal_target: str # a local path or a URL to open in the file manager + unmount_command: list[str] | None + + +def webdav_mount_supported() -> bool: + """Whether this host has a tool to auto-mount a WebDAV volume.""" + if sys.platform == "darwin": + return shutil.which("mount_webdav") is not None + if sys.platform == "win32": + return shutil.which("net") is not None + return shutil.which("gio") is not None + + +def _sanitize_label(label: str) -> str: + """Make ``label`` safe as a single filesystem path component.""" + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", label) + cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-._") + return (cleaned or "webdav")[:100] + + +def _dav_url(http_url: str) -> str: + """Convert an http(s) URL to the dav(s) scheme understood by ``gio``.""" + if http_url.startswith("https://"): + return "davs://" + http_url[len("https://") :] + if http_url.startswith("http://"): + return "dav://" + http_url[len("http://") :] + return http_url + + +async def _run(command: list[str]) -> tuple[int, str]: + proc = await asyncio.create_subprocess_exec( + *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + out, _ = await proc.communicate() + return (proc.returncode or 0), out.decode(errors="replace") + + +async def mount_webdav_volume(url: str, *, label: str = "rpc-webdav") -> MountedVolume | None: + """Mount ``url`` locally using the host's native mechanism. + + :param url: the WebDAV URL to mount. + :param label: a descriptive name for the mount point (macOS), so it is easy to spot in Finder; + a random suffix is always appended. + :return: a :class:`MountedVolume` on success, or ``None`` if unsupported or the mount failed. + """ + try: + if sys.platform == "darwin": + if shutil.which("mount_webdav") is None: + return None + mount_point = tempfile.mkdtemp(prefix=_sanitize_label(label) + "-") + rc, _ = await _run(["mount_webdav", "-S", url, mount_point]) + if rc != 0: + return None + return MountedVolume(mount_point, ["umount", mount_point]) + + if sys.platform == "win32": + if shutil.which("net") is None: + return None + rc, out = await _run(["net", "use", "*", url]) + if rc != 0: + return None + match = re.search(r"\b([A-Za-z]):", out) + if match is None: + return None + drive = match.group(1) + ":" + return MountedVolume(drive, ["net", "use", drive, "/delete", "/y"]) + + # linux / other unixes + if shutil.which("gio") is None: + return None + dav = _dav_url(url) + rc, _ = await _run(["gio", "mount", dav]) + if rc != 0: + return None + return MountedVolume(dav, ["gio", "mount", "-u", dav]) + except OSError: + return None + + +async def unmount_webdav_volume(mounted: MountedVolume) -> None: + """Unmount a previously mounted volume, best-effort.""" + if mounted.unmount_command is not None: + with suppress(OSError): + await _run(mounted.unmount_command) + + +def _opener_command(target: str) -> list[str] | None: + """Command to open ``target`` (a path or URL) in the OS file manager, or ``None``.""" + if sys.platform == "darwin": + return ["open", target] + if sys.platform == "win32": + return ["explorer", target] + if shutil.which("xdg-open") is not None: + return ["xdg-open", target] + if shutil.which("gio") is not None: + return ["gio", "open", target] + return None + + +async def reveal_in_file_manager(target: str) -> None: + """Open ``target`` in the OS file manager, best-effort (never raises).""" + command = _opener_command(target) + if command is None: + return + with suppress(OSError): + await _run(command) diff --git a/src/rpcclient/tests/test_webdav.py b/src/rpcclient/tests/test_webdav.py new file mode 100644 index 00000000..405cd5e8 --- /dev/null +++ b/src/rpcclient/tests/test_webdav.py @@ -0,0 +1,237 @@ +import asyncio +import os +from contextlib import suppress + +import click +import httpx +import pytest + +from tests._types import Client + + +def test_webdav_cli_subcommand_registered() -> None: + from rpcclient.__main__ import rpcclient + + assert isinstance(rpcclient, click.Group) + assert "webdav" in rpcclient.commands + option_names = {param.name for param in rpcclient.commands["webdav"].params} + assert "mount" in option_names + + +def test_rpcdav_cli_command_registered() -> None: + from rpcclient.__main__ import rpcdav + + assert isinstance(rpcdav, click.Command) + param_names = {param.name for param in rpcdav.params} + assert "hostname" in param_names + assert "mount" in param_names + + +def test_served_path_is_positional_argument() -> None: + from rpcclient.__main__ import rpcclient, rpcdav + + for command in (rpcdav, rpcclient.commands["webdav"]): + path_param = next(param for param in command.params if param.name == "path") + assert isinstance(path_param, click.Argument) + + +def test_mount_requires_mount_tool(monkeypatch) -> None: + import shutil + + from rpcclient import __main__ as cli + + monkeypatch.setattr(shutil, "which", lambda name: None) + + # --mount on a host without any mount tool must fail early, before connecting + with pytest.raises(click.UsageError): + cli._require_mount_tool(mount=True) + + # without --mount the missing tool is irrelevant + cli._require_mount_tool(mount=False) + + +@pytest.mark.parametrize("platform, tool", [("darwin", "mount_webdav"), ("win32", "net"), ("linux", "gio")]) +def test_webdav_mount_supported_per_platform(monkeypatch, platform, tool) -> None: + import shutil + + from rpcclient.core import webdav_mount + + monkeypatch.setattr(webdav_mount.sys, "platform", platform) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/" + name if name == tool else None) + assert webdav_mount.webdav_mount_supported() is True + + monkeypatch.setattr(shutil, "which", lambda name: None) + assert webdav_mount.webdav_mount_supported() is False + + +@pytest.mark.parametrize("platform, expected", [("darwin", ["open", "/mnt"]), ("win32", ["explorer", "/mnt"])]) +def test_opener_command_per_platform(monkeypatch, platform, expected) -> None: + from rpcclient.core import webdav_mount + + monkeypatch.setattr(webdav_mount.sys, "platform", platform) + assert webdav_mount._opener_command("/mnt") == expected + + +@pytest.mark.asyncio +async def test_mount_returns_none_when_tool_absent(monkeypatch) -> None: + import shutil + + from rpcclient.core import webdav_mount + + monkeypatch.setattr(shutil, "which", lambda name: None) + assert await webdav_mount.mount_webdav_volume("http://127.0.0.1:1234/") is None + + +@pytest.mark.parametrize( + "label, expected", + [ + ("rpc-127.0.0.1-/var/mobile", "rpc-127.0.0.1-var-mobile"), + ("rpc-127.0.0.1-/", "rpc-127.0.0.1"), + ("", "webdav"), + ], +) +def test_sanitize_label(label, expected) -> None: + from rpcclient.core import webdav_mount + + assert webdav_mount._sanitize_label(label) == expected + + +@pytest.mark.asyncio +async def test_get_serves_existing_file(client: Client, tmp_path) -> None: + target = tmp_path / "hello.txt" + await client.fs.write_file(target, b"hello from remote") + + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.get(f"{server.url}hello.txt") + assert response.status_code == 200 + assert response.content == b"hello from remote" + 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), + # standing in for the read-only-filesystem case Finder hits writing /.DS_Store + await client.fs.write_file(tmp_path / "afile", b"x") + + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.put(f"{server.url}afile/blocked.txt", content=b"data") + assert response.status_code == 403 + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_put_ds_store_is_swallowed_without_writing_remote(client: Client, tmp_path) -> None: + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.put(f"{server.url}.DS_Store", content=b"\x00\x01mac metadata") + assert response.status_code in (200, 201, 204) + finally: + await server.stop() + + # the Apple metadata file must NOT have been written to the remote target + assert not await (tmp_path / ".DS_Store").exists() + + +@pytest.mark.asyncio +async def test_get_special_file_returns_403_without_wedging(client: Client, tmp_path) -> None: + # a fifo's read() blocks forever in the target; opening it would hold the single + # serialized RPC channel and wedge the whole mount. The provider must refuse it. + os.mkfifo(str(tmp_path / "afifo")) + await client.fs.write_file(tmp_path / "after.txt", b"still alive") + + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient(timeout=10.0) as http: + response = await http.get(f"{server.url}afifo") + assert response.status_code == 403 + # the mount must still serve other requests afterwards (not wedged) + alive = await http.get(f"{server.url}after.txt") + assert alive.status_code == 200 + assert alive.content == b"still alive" + finally: + with suppress(asyncio.TimeoutError): + await asyncio.wait_for(server.stop(), timeout=5) + + +@pytest.mark.asyncio +async def test_propfind_lists_directory(client: Client, tmp_path) -> None: + await client.fs.write_file(tmp_path / "a.txt", b"a") + await client.fs.mkdir(tmp_path / "sub") + + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.request("PROPFIND", server.url, headers={"Depth": "1"}) + assert response.status_code == 207 + assert "a.txt" in response.text + assert "sub" in response.text + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_put_writes_file(client: Client, tmp_path) -> None: + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.put(f"{server.url}created.txt", content=b"payload") + assert response.status_code in (200, 201, 204) + finally: + await server.stop() + + assert await client.fs.read_file(tmp_path / "created.txt") == b"payload" + + +@pytest.mark.asyncio +async def test_delete_removes_file(client: Client, tmp_path) -> None: + target = tmp_path / "doomed.txt" + await client.fs.write_file(target, b"bye") + + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.delete(f"{server.url}doomed.txt") + assert response.status_code in (200, 204) + finally: + await server.stop() + + assert not await (tmp_path / "doomed.txt").exists() + + +@pytest.mark.asyncio +async def test_mkcol_creates_directory(client: Client, tmp_path) -> None: + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.request("MKCOL", f"{server.url}newdir") + assert response.status_code == 201 + finally: + await server.stop() + + assert await (tmp_path / "newdir").is_dir() + + +@pytest.mark.asyncio +async def test_move_renames_file(client: Client, tmp_path) -> None: + await client.fs.write_file(tmp_path / "before.txt", b"content") + + server = await client.webdav.serve(str(tmp_path), port=0) + try: + async with httpx.AsyncClient() as http: + response = await http.request( + "MOVE", f"{server.url}before.txt", headers={"Destination": f"{server.url}after.txt"} + ) + assert response.status_code in (201, 204) + finally: + await server.stop() + + assert not await (tmp_path / "before.txt").exists() + assert await client.fs.read_file(tmp_path / "after.txt") == b"content" From a9b0f49d669a147ac4152ac92734379ff2fdfb6c Mon Sep 17 00:00:00 2001 From: doronz88 Date: Tue, 18 Aug 2026 22:51:02 +0300 Subject: [PATCH 2/2] docs(rpcclient): document WebDAV mounting Add a "Mounting in Finder (WebDAV)" guide covering rpcdav and the rpcclient webdav subcommand, and surface the WebDav subsystem in the core API reference. Notes cross-platform mounting, client-side caching (out-of-band changes can appear stale until the client revalidates), read-write behavior, .DS_Store handling, the non-regular-file limitation, and the chmod/chown WebDAV limitation. --- docs/api/core.md | 6 +++ docs/guides/mounting-webdav.md | 96 ++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 3 files changed, 104 insertions(+) create mode 100644 docs/guides/mounting-webdav.md diff --git a/docs/api/core.md b/docs/api/core.md index 6857a441..5316388f 100644 --- a/docs/api/core.md +++ b/docs/api/core.md @@ -4,6 +4,12 @@ ::: rpcclient.core.client.CoreClient +## WebDAV mounting + +::: rpcclient.core.subsystems.webdav.WebDav + +::: rpcclient.core.subsystems.webdav.WebDavServer + ## Symbols ::: rpcclient.core.symbol diff --git a/docs/guides/mounting-webdav.md b/docs/guides/mounting-webdav.md new file mode 100644 index 00000000..0ef4c460 --- /dev/null +++ b/docs/guides/mounting-webdav.md @@ -0,0 +1,96 @@ +# Mounting a remote path in Finder (WebDAV) + +`rpcclient` can expose any path on the remote target as a local **WebDAV** volume, so macOS +Finder (or any WebDAV client) can browse and edit the remote filesystem read-write as if it were +mounted. + +A small async WebDAV server runs **inside the `rpcclient` process**; every WebDAV request is +translated into `client.fs` calls against the target. Nothing runs on the target and there are no +server-side changes, so it works the same for iOS, macOS, and Linux targets. + +WebDAV is used rather than FTP because macOS Finder mounts FTP **read-only** — it only allows +writes to servers that implement WebDAV locking, which this server does. + +## Quick start + +Serve the target's root and mount it in your file manager in one step: + +```shell +rpcdav HOSTNAME --mount +``` + +`--mount` uses the host's native WebDAV mechanism — `mount_webdav` on macOS, `net use` on +Windows, `gio mount` on Linux — and reveals the volume in the file manager. If none is +available, it falls back to printing the URL to open manually. + +Serve a specific path instead of `/`: + +```shell +rpcdav HOSTNAME /var/mobile/Containers --mount +``` + +The same is available as a subcommand of `rpcclient`: + +```shell +rpcclient HOSTNAME webdav [PATH] [--mount] +``` + +Press `Ctrl-C` to unmount and stop the server. + +## Mounting manually + +Without `--mount`, the server prints its local URL: + +``` +WebDAV server serving '/' at http://127.0.0.1:52314/ +In Finder: Go → Connect to Server → http://127.0.0.1:52314/ +``` + +In Finder, choose **Go → Connect to Server** (`⌘K`) and enter that URL (on Linux/Windows, open +it in your file manager's "connect to server" equivalent, or use any WebDAV client). + +## Usage + +```none +Usage: rpcdav [OPTIONS] HOSTNAME [PATH] + + Serve a remote HOSTNAME's PATH (default: /) over WebDAV for local mounting. + +Options: + -p, --port INTEGER TCP port to connect to + -r, --rebind-symbols reload all symbols upon connection + -l, --load-all-libraries load all libraries + --mount mount the served path locally and reveal it in your file manager + --host TEXT local interface to bind + --bind-port INTEGER local TCP port (0 picks a free port) + --readonly expose the path read-only +``` + +## From a script + +The `webdav` subsystem is available on every client: + +```python +server = await client.webdav.serve("/var/mobile", host="127.0.0.1", port=0) +print(server.url) # http://127.0.0.1:/ +# ... use the mount ... +await server.stop() +``` + +## Notes and limitations + +- **Freshness / stale views.** A mounted WebDAV volume is a *client-cached network filesystem*. + Changes you make through the mount are immediate, but changes made on the target out-of-band may + appear stale until the client revalidates. WebDAV has no live-reload mechanism, and macOS's + WebDAV client caches directory listings and attributes at the kernel level regardless of server + hints. To force a refresh, navigate out of the folder and back (or reopen the window). +- **Read-write** browse, read, create, edit, `mkdir`, rename/move, and delete all propagate to the + target. +- **Finder metadata** (`.DS_Store`, AppleDouble `._*`, …) writes are swallowed — reported as + success but never written to the target, so they don't clutter it or fail on read-only roots. +- **`chmod` / `chown` do not propagate.** WebDAV has no representation for POSIX permissions or + ownership, and macOS's WebDAV client never sends them — such changes on the mount are local + no-ops. Use `client.fs.chmod(...)` / `client.fs.chown(...)` for real remote permission changes. +- **New files and directories** are created by the process the `rpcserver` runs as, so they are + owned by that user (e.g. `root` when the server runs as root), with the server's default + permissions — not your local user. diff --git a/mkdocs.yml b/mkdocs.yml index ca1d38bd..b54c6c91 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ plugins: Guides: - guides/quickstart.md - guides/calling-native-functions.md + - guides/mounting-webdav.md API reference: - api/index.md - api/core.md @@ -95,6 +96,7 @@ nav: - Guides: - Quick start: guides/quickstart.md - Calling native functions: guides/calling-native-functions.md + - Mounting in Finder (WebDAV): guides/mounting-webdav.md - API Reference: - Overview: api/index.md - Core client: api/core.md