From 59e2ad0e7703d7c3c69827f5de913ba016cef68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Sanz?= Date: Thu, 1 Oct 2026 16:33:20 +0200 Subject: [PATCH] fix: stop reading the filesystem from ordinary string parameters --- README.md | 20 ++--- pyproject.toml | 2 +- runware/client.py | 8 +- runware/utils/file.py | 42 +-------- tests/test_file_encoding.py | 169 ++++++++++++++++++++++-------------- uv.lock | 2 +- 6 files changed, 118 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index ae379d8..a26e476 100644 --- a/README.md +++ b/README.md @@ -639,26 +639,20 @@ The per-model methods (`get_model`, `get_model_examples`, `get_model_pricing`) a ## File helpers -`file_to_data_uri` encodes a local file as a `data:` URI for passing as input: +**The SDK never reads the filesystem on its own.** A string you pass as a parameter is sent as that string, prompts and media parameters alike. When you want a file's contents on the wire, you say so: ```python -from pathlib import Path -from runware import file_to_data_uri +from runware import file_to_base64, file_to_data_uri -data_uri = file_to_data_uri(Path("photo.jpg")) -await client.media_storage({"operation": "upload", "media": data_uri}) +await client.run({"model": "...", "seedImage": file_to_base64("photo.jpg")}) +await client.media_storage({"operation": "upload", "media": file_to_data_uri("photo.jpg")}) ``` -Accepts both `Path` and `bytes` — `bytes` is useful when the file lives in memory (e.g. a freshly downloaded blob). +`file_to_base64` returns raw base64, with no `data:` prefix and no MIME type, which is what a media parameter takes most directly: the server reads the real format from the bytes. `file_to_data_uri` returns a `data:;base64,...` URI, taking the MIME from the file's extension. -`file_to_base64` does the same read but returns raw base64 with no `data:` prefix or MIME type (the server sniffs the real format from the bytes). +Both accept a `str`, a `Path`, `bytes`, or a file-like object, so a blob you already hold in memory needs no round trip through disk. -You usually don't need either helper for inputs: `run()` and `media_storage` auto-encode local file paths. Any string value (recursively, including nested dicts and lists) that points to an existing file on disk is read and replaced with its base64 before the request is sent. URLs, UUIDs, data URIs, existing base64, and prompts pass through untouched. - -```python -await client.run({"model": "...", "seedImage": "./photo.jpg"}) -await client.run({"model": "...", "referenceImages": ["./a.jpg", "./b.jpg"]}) -``` +> Before 1.7.1 the SDK replaced any string that happened to name a readable file with that file's contents, in every parameter. Passing a path straight to `seedImage` no longer works: wrap it in `file_to_base64`. ## Custom dependencies diff --git a/pyproject.toml b/pyproject.toml index 686d4e5..4180852 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runware-sdk" -version = "1.7.0" +version = "1.7.1" description = "Async Python SDK for the Runware platform — image, video, audio, text, and 3D generation, plus upscaling, background removal, and more, over REST or WebSocket with typed parameters and runtime validation." readme = "README.md" requires-python = ">=3.11" diff --git a/runware/client.py b/runware/client.py index a9562b9..7dda35b 100644 --- a/runware/client.py +++ b/runware/client.py @@ -82,7 +82,6 @@ operation_task_types as _bundled_operation_task_types, ) from .types.transport import RequestOptions, WireFrame -from .utils.file import encode_local_files from .validate import validate_tasks @@ -264,7 +263,6 @@ async def run( # pyright: ignore[reportInconsistentOverload] options: RunOptions | None = None, ) -> list[dict[str, Any]]: # pyright: ignore[reportExplicitAny] """Run an inference task.""" - params = cast("dict[str, Any]", encode_local_files(params)) # pyright: ignore[reportExplicitAny] params = await self._normalize_model_param(params) task_type = await self._resolve_task_type(params) task_uuid = str(params.get("taskUUID") or uuid.uuid4()) @@ -379,19 +377,17 @@ async def image_upload( self, params: ImageUploadParams, options: RunOptions | None = None, ) -> list[ImageUploadResult]: """Deprecated: use :meth:`media_storage`, which handles any media type and supports deletion.""" - encoded = cast("dict[str, Any]", encode_local_files(dict(params))) # pyright: ignore[reportExplicitAny] return cast( list[ImageUploadResult], - await self._utility("imageUpload", encoded, options), + await self._utility("imageUpload", dict(params), options), ) async def media_storage( self, params: MediaStorageParams, options: RunOptions | None = None, ) -> list[MediaStorageResult]: - encoded = cast("dict[str, Any]", encode_local_files(dict(params))) # pyright: ignore[reportExplicitAny] return cast( list[MediaStorageResult], - await self._utility("mediaStorage", encoded, options), + await self._utility("mediaStorage", dict(params), options), ) async def account_management( diff --git a/runware/utils/file.py b/runware/utils/file.py index 9b21215..f8f372a 100644 --- a/runware/utils/file.py +++ b/runware/utils/file.py @@ -5,16 +5,12 @@ import base64 import io import mimetypes -import os from pathlib import Path -from typing import BinaryIO, cast +from typing import BinaryIO + # Strings longer than this can't plausibly be a filesystem path — a base64 blob # or data URI is far longer. Skipping them avoids hitting the disk for them. -_MAX_PATH_LEN = 4096 -_REMOTE_PREFIXES = ("http://", "https://", "data:") - - def _read_bytes(source: str | Path | bytes | BinaryIO) -> bytes: if isinstance(source, (str, Path)): return Path(source).read_bytes() @@ -44,40 +40,6 @@ def file_to_base64(source: str | Path | bytes | BinaryIO) -> str: return base64.b64encode(_read_bytes(source)).decode("ascii") -def _looks_like_local_file(value: str) -> bool: - if value.startswith(_REMOTE_PREFIXES): - return False - if len(value) > _MAX_PATH_LEN: - return False - try: - return os.path.isfile(value) - except (OSError, ValueError): - return False - - -def encode_local_files(value: object) -> object: - """ - Recursively walk a params object (dicts, lists, strings) and replace any - string that points to an existing local file with its base64 contents. - - URLs, data URIs, UUIDs, prompts, existing base64, numbers, and bools pass - through untouched — only strings that resolve to a real file on disk are - converted. A string that merely looks like a path but doesn't exist is left - as-is (no error raised). - """ - if isinstance(value, str): - if _looks_like_local_file(value): - return file_to_base64(value) - return value - if isinstance(value, dict): - items = cast("dict[object, object]", value) - return {k: encode_local_files(v) for k, v in items.items()} - if isinstance(value, list): - items_list = cast("list[object]", value) - return [encode_local_files(item) for item in items_list] - return value - - def file_to_data_uri(source: str | Path | bytes | BinaryIO) -> str: """ Encode a file, bytes blob, or file-like into a `data:;base64,...` URI. diff --git a/tests/test_file_encoding.py b/tests/test_file_encoding.py index faec5cf..15a4dc3 100644 --- a/tests/test_file_encoding.py +++ b/tests/test_file_encoding.py @@ -1,6 +1,14 @@ """ -Tests for runware/utils/file.py — auto-encoding of local file paths to base64 -when passed as inputs to client.run(). +Tests for the rule that the SDK never reads the filesystem on its own. + +Until 1.7.0 every string in the params was checked against the filesystem and +replaced with the file's base64 if it happened to name one, which made any caller +that forwarded user text into ``run()`` read local files on request. A local +file now travels only through ``file_to_base64`` or ``file_to_data_uri``, which +the caller has to reach for. + +The case that matters is the one that used to leak: a prompt that is exactly a +path to a real file has to arrive at the transport as that path. """ from __future__ import annotations @@ -12,9 +20,8 @@ import pytest -from runware import Runware, file_to_base64 +from runware import Runware, file_to_base64, file_to_data_uri from runware.transport.rest import RestTransport -from runware.utils.file import encode_local_files def _patch_rest(client: Runware, response: Any) -> AsyncMock: @@ -25,81 +32,115 @@ def _patch_rest(client: Runware, response: Any) -> AsyncMock: return mock -class TestEncodeLocalFiles: - def test_file_to_base64_has_no_prefix(self, tmp_path: Path) -> None: - f = tmp_path / "photo.jpg" - f.write_bytes(b"\xff\xd8\xff\xe0binary") - expected = base64.b64encode(b"\xff\xd8\xff\xe0binary").decode("ascii") - assert file_to_base64(f) == expected - assert not file_to_base64(f).startswith("data:") - - def test_existing_file_converted(self, tmp_path: Path) -> None: - f = tmp_path / "a.png" - f.write_bytes(b"pixels") - out = encode_local_files(str(f)) - assert out == base64.b64encode(b"pixels").decode("ascii") - - def test_nonexistent_path_passes_through(self) -> None: - assert encode_local_files("./not-a-real-file.jpg") == "./not-a-real-file.jpg" - - def test_url_uuid_data_uri_prompt_pass_through(self) -> None: - assert encode_local_files("https://x.com/a.jpg") == "https://x.com/a.jpg" - assert encode_local_files("http://x.com/a.jpg") == "http://x.com/a.jpg" - assert encode_local_files("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA" - assert encode_local_files("a paragraph that describes a cat") == "a paragraph that describes a cat" - assert encode_local_files("9f3c-uuid-like-1234") == "9f3c-uuid-like-1234" - - def test_recurses_into_dicts_and_lists(self, tmp_path: Path) -> None: - a = tmp_path / "a.jpg" - b = tmp_path / "b.jpg" - a.write_bytes(b"AAA") - b.write_bytes(b"BBB") - from typing import cast - - out = cast( - "dict[str, Any]", - encode_local_files( - { - "inputs": {"image": str(a)}, - "referenceImages": [str(b), "https://x.com/c.jpg"], - "positivePrompt": "a cat", - "width": 1024, - "flag": True, - } - ), +async def _sent_for(params: dict[str, Any]) -> dict[str, Any]: + client = Runware(api_key="sk-test", transport="rest") + mock = _patch_rest( + client, + {"data": [{"taskUUID": "u1", "imageURL": "https://x.jpg", "imageUUID": "i1"}]}, + ) + await client.run(params) + return mock.send_request.call_args_list[0].args[0] + + +class TestTheSdkDoesNotReadTheFilesystem: + @pytest.mark.asyncio + async def test_a_prompt_that_is_a_real_path_is_sent_as_that_path( + self, tmp_path: Path + ) -> None: + secret = tmp_path / "secrets.env" + secret.write_bytes(b"API_KEY=leaked") + + sent = await _sent_for( + { + "taskType": "imageInference", + "taskUUID": "u1", + "model": "runware:101@1", + "positivePrompt": str(secret), + "width": 1024, + "height": 1024, + "deliveryMethod": "sync", + } ) - assert out["inputs"]["image"] == base64.b64encode(b"AAA").decode("ascii") - assert out["referenceImages"][0] == base64.b64encode(b"BBB").decode("ascii") - assert out["referenceImages"][1] == "https://x.com/c.jpg" - assert out["positivePrompt"] == "a cat" - assert out["width"] == 1024 - assert out["flag"] is True + assert sent["positivePrompt"] == str(secret) + assert base64.b64encode(b"API_KEY=leaked").decode("ascii") not in str(sent) -class TestRunAutoEncodes: @pytest.mark.asyncio - async def test_run_encodes_local_seed_image(self, tmp_path: Path) -> None: + async def test_a_media_param_that_is_a_real_path_is_sent_as_that_path( + self, tmp_path: Path + ) -> None: f = tmp_path / "seed.jpg" f.write_bytes(b"\x89PNG seed bytes") - client = Runware(api_key="sk-test", transport="rest") - mock = _patch_rest( - client, - {"data": [{"taskUUID": "u1", "imageURL": "https://x.jpg", "imageUUID": "i1"}]}, - ) - await client.run( + + sent = await _sent_for( { "taskType": "imageInference", "taskUUID": "u1", "model": "runware:101@1", "seedImage": str(f), - "referenceImages": ["https://x.com/keep.jpg"], "positivePrompt": "x", "width": 1024, "height": 1024, "deliveryMethod": "sync", } ) - sent = mock.send_request.call_args_list[0].args[0] + + assert sent["seedImage"] == str(f) + + @pytest.mark.asyncio + async def test_a_nested_path_is_left_alone(self, tmp_path: Path) -> None: + f = tmp_path / "nested.txt" + f.write_bytes(b"nested bytes") + + sent = await _sent_for( + { + "taskType": "imageInference", + "taskUUID": "u1", + "model": "runware:101@1", + "positivePrompt": "x", + "referenceImages": [str(f)], + "width": 1024, + "height": 1024, + "deliveryMethod": "sync", + } + ) + + assert sent["referenceImages"] == [str(f)] + + +class TestALocalFileTravelsWhenTheCallerAsks: + def test_file_to_base64_has_no_prefix(self, tmp_path: Path) -> None: + f = tmp_path / "photo.jpg" + f.write_bytes(b"\x89PNG photo bytes") + + assert file_to_base64(str(f)) == base64.b64encode(b"\x89PNG photo bytes").decode("ascii") + + def test_file_to_data_uri_carries_the_mime(self, tmp_path: Path) -> None: + f = tmp_path / "photo.jpg" + f.write_bytes(b"bytes") + + encoded = base64.b64encode(b"bytes").decode("ascii") + assert file_to_data_uri(str(f)) == f"data:image/jpeg;base64,{encoded}" + + def test_bytes_the_caller_already_holds(self) -> None: + assert file_to_base64(b"\x01\x02\x03") == base64.b64encode(b"\x01\x02\x03").decode("ascii") + + @pytest.mark.asyncio + async def test_it_reaches_the_transport_when_encoded_first(self, tmp_path: Path) -> None: + f = tmp_path / "seed.jpg" + f.write_bytes(b"\x89PNG seed bytes") + + sent = await _sent_for( + { + "taskType": "imageInference", + "taskUUID": "u1", + "model": "runware:101@1", + "seedImage": file_to_base64(str(f)), + "positivePrompt": "x", + "width": 1024, + "height": 1024, + "deliveryMethod": "sync", + } + ) + assert sent["seedImage"] == base64.b64encode(b"\x89PNG seed bytes").decode("ascii") - assert sent["referenceImages"] == ["https://x.com/keep.jpg"] - assert sent["positivePrompt"] == "x" diff --git a/uv.lock b/uv.lock index 0ba9e74..3b3d54a 100644 --- a/uv.lock +++ b/uv.lock @@ -666,7 +666,7 @@ wheels = [ [[package]] name = "runware-sdk" -version = "1.7.0" +version = "1.7.1" source = { editable = "." } dependencies = [ { name = "aiohttp" },