Skip to content

Commit 2a6faed

Browse files
authored
Merge pull request #257 from RonaldJEN/main
Fix file download path encoding and host volume validation errors
2 parents 3270de5 + 94831fc commit 2a6faed

6 files changed

Lines changed: 84 additions & 47 deletions

File tree

sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from collections.abc import AsyncIterator
2626
from io import IOBase, TextIOBase
2727
from typing import TypedDict
28+
from urllib.parse import quote
2829

2930
import httpx
3031

@@ -52,7 +53,7 @@
5253

5354
class _DownloadRequest(TypedDict):
5455
url: str
55-
params: dict[str, str]
56+
params: dict[str, str] | None
5657
headers: dict[str, str]
5758

5859

@@ -158,11 +159,13 @@ async def read_bytes(
158159
request_data = self._build_download_request(path, range_header)
159160
client = await self._get_httpx_client()
160161

161-
response = await client.get(
162-
request_data["url"],
163-
params=request_data["params"],
164-
headers=request_data["headers"],
165-
)
162+
request_kwargs: dict[str, dict[str, str]] = {
163+
"headers": request_data["headers"],
164+
}
165+
if request_data["params"] is not None:
166+
request_kwargs["params"] = request_data["params"]
167+
168+
response = await client.get(request_data["url"], **request_kwargs)
166169
response.raise_for_status()
167170
return response.content
168171
except Exception as e:
@@ -186,12 +189,13 @@ async def read_bytes_stream(
186189
params = request_data["params"]
187190
headers = request_data["headers"]
188191

189-
request = client.build_request(
190-
"GET",
191-
url,
192-
params=params,
193-
headers=headers,
194-
)
192+
request_kwargs: dict[str, dict[str, str]] = {
193+
"headers": headers,
194+
}
195+
if params is not None:
196+
request_kwargs["params"] = params
197+
198+
request = client.build_request("GET", url, **request_kwargs)
195199

196200
response = await client.send(request, stream=True)
197201

@@ -476,15 +480,15 @@ def _build_download_request(
476480
Returns:
477481
Dictionary containing URL, parameters, and headers for the request
478482
"""
479-
url = self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)
480-
params = {"path": path}
483+
encoded_path = quote(path, safe="/")
484+
url = f"{self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)}?path={encoded_path}"
481485
headers: dict[str, str] = {}
482486

483487
if range_header:
484488
headers["Range"] = range_header
485489

486490
return {
487491
"url": url,
488-
"params": params,
492+
"params": None,
489493
"headers": headers,
490494
}

sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from collections.abc import Iterator
2323
from io import IOBase, TextIOBase
2424
from typing import TypedDict
25+
from urllib.parse import quote
2526

2627
import httpx
2728

@@ -49,7 +50,7 @@
4950

5051
class _DownloadRequest(TypedDict):
5152
url: str
52-
params: dict[str, str]
53+
params: dict[str, str] | None
5354
headers: dict[str, str]
5455

5556

@@ -87,12 +88,12 @@ def _get_execd_url(self, path: str) -> str:
8788
return f"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}{path}"
8889

8990
def _build_download_request(self, path: str, range_header: str | None = None) -> _DownloadRequest:
90-
url = self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)
91-
params = {"path": path}
91+
encoded_path = quote(path, safe="/")
92+
url = f"{self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)}?path={encoded_path}"
9293
headers: dict[str, str] = {}
9394
if range_header:
9495
headers["Range"] = range_header
95-
return {"url": url, "params": params, "headers": headers}
96+
return {"url": url, "params": None, "headers": headers}
9697

9798
def read_file(
9899
self,
@@ -108,11 +109,13 @@ def read_bytes(self, path: str, *, range_header: str | None = None) -> bytes:
108109
logger.debug("Reading file as bytes: %s", path)
109110
try:
110111
request_data = self._build_download_request(path, range_header)
111-
response = self._httpx_client.get(
112-
request_data["url"],
113-
params=request_data["params"],
114-
headers=request_data["headers"],
115-
)
112+
request_kwargs: dict[str, dict[str, str]] = {
113+
"headers": request_data["headers"],
114+
}
115+
if request_data["params"] is not None:
116+
request_kwargs["params"] = request_data["params"]
117+
118+
response = self._httpx_client.get(request_data["url"], **request_kwargs)
116119
response.raise_for_status()
117120
return response.content
118121
except Exception as e:
@@ -128,7 +131,11 @@ def read_bytes_stream(
128131
params = request_data["params"]
129132
headers = request_data["headers"]
130133

131-
request = self._httpx_client.build_request("GET", url, params=params, headers=headers)
134+
request_kwargs: dict[str, dict[str, str]] = {"headers": headers}
135+
if params is not None:
136+
request_kwargs["params"] = params
137+
138+
request = self._httpx_client.build_request("GET", url, **request_kwargs)
132139
response = self._httpx_client.send(request, stream=True)
133140

134141
if response.status_code >= 300:

server/src/api/lifecycle.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,11 @@ async def proxy_sandbox_endpoint_request(request: Request, sandbox_id: str, port
432432

433433
target_host = endpoint.endpoint
434434
query_string = request.url.query
435-
target_url = f"http://{target_host}/{full_path}"
436-
if query_string:
437-
target_url = f"{target_url}?{query_string}"
435+
target_url = (
436+
f"http://{target_host}/{full_path}?{query_string}"
437+
if query_string
438+
else f"http://{target_host}/{full_path}"
439+
)
438440

439441
client: httpx.AsyncClient = request.app.state.http_client
440442

server/src/services/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class SandboxErrorCodes:
6969
INVALID_PVC_NAME = "VOLUME::INVALID_PVC_NAME"
7070
UNSUPPORTED_VOLUME_BACKEND = "VOLUME::UNSUPPORTED_BACKEND"
7171
HOST_PATH_NOT_FOUND = "VOLUME::HOST_PATH_NOT_FOUND"
72+
HOST_PATH_CREATE_FAILED = "VOLUME::HOST_PATH_CREATE_FAILED"
7273
PVC_VOLUME_NOT_FOUND = "VOLUME::PVC_NOT_FOUND"
7374
PVC_VOLUME_INSPECT_FAILED = "VOLUME::PVC_INSPECT_FAILED"
7475
PVC_SUBPATH_UNSUPPORTED_DRIVER = "VOLUME::PVC_SUBPATH_UNSUPPORTED_DRIVER"

server/src/services/docker.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -976,14 +976,15 @@ def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None
976976
Docker-specific validation for host bind mount volumes.
977977
978978
Validates that the resolved host path (host.path + optional subPath)
979-
exists on the filesystem and remains within allowed prefixes.
979+
remains within allowed prefixes, then ensures the directory exists on
980+
the filesystem — creating it automatically if it does not.
980981
981982
Args:
982983
volume: Volume with host backend.
983984
allowed_prefixes: Optional allowlist of host path prefixes.
984985
985986
Raises:
986-
HTTPException: When the resolved path is invalid or missing.
987+
HTTPException: When the resolved path is invalid or cannot be created.
987988
"""
988989
resolved_path = volume.host.path
989990
if volume.sub_path:
@@ -996,14 +997,16 @@ def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None
996997
if allowed_prefixes and resolved_path != volume.host.path:
997998
ensure_valid_host_path(resolved_path, allowed_prefixes)
998999

999-
if not os.path.exists(resolved_path):
1000+
try:
1001+
os.makedirs(resolved_path, exist_ok=True)
1002+
except OSError as e:
10001003
raise HTTPException(
1001-
status_code=status.HTTP_400_BAD_REQUEST,
1004+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
10021005
detail={
1003-
"code": SandboxErrorCodes.HOST_PATH_NOT_FOUND,
1006+
"code": SandboxErrorCodes.HOST_PATH_CREATE_FAILED,
10041007
"message": (
1005-
f"Volume '{volume.name}': resolved host path '{resolved_path}' "
1006-
"does not exist. Host paths must exist before sandbox creation."
1008+
f"Volume '{volume.name}': could not ensure host path "
1009+
f"directory exists at '{resolved_path}': {type(e).__name__}"
10071010
),
10081011
},
10091012
)

server/tests/test_docker_service.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,7 @@ def test_pvc_subpath_binds_resolved_to_mountpoint(self, mock_docker):
988988
assert binds[0] == "/var/lib/docker/volumes/my-vol/_data/datasets/train:/mnt/train:ro"
989989

990990
def test_host_path_not_found_rejected(self, mock_docker):
991-
"""Host path that does not exist should be rejected."""
991+
"""Host path create failure should return 500 with HOST_PATH_CREATE_FAILED."""
992992
mock_client = MagicMock()
993993
mock_client.containers.list.return_value = []
994994
mock_docker.from_env.return_value = mock_client
@@ -1012,11 +1012,12 @@ def test_host_path_not_found_rejected(self, mock_docker):
10121012
],
10131013
)
10141014

1015-
with pytest.raises(HTTPException) as exc_info:
1016-
service.create_sandbox(request)
1015+
with patch("src.services.docker.os.makedirs", side_effect=PermissionError("denied")):
1016+
with pytest.raises(HTTPException) as exc_info:
1017+
service.create_sandbox(request)
10171018

1018-
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
1019-
assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_FOUND
1019+
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
1020+
assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_CREATE_FAILED
10201021

10211022
def test_host_path_not_in_allowlist_rejected(self, mock_docker):
10221023
"""Host path not in allowlist should be rejected."""
@@ -1167,17 +1168,21 @@ def test_host_volume_with_subpath_resolved_correctly(self, mock_docker):
11671168
assert len(binds) == 1
11681169
assert binds[0] == f"{sub_dir}:/mnt/work:ro"
11691170

1170-
def test_host_subpath_not_found_rejected(self, mock_docker):
1171-
"""Host volume with non-existent subPath should be rejected."""
1171+
def test_host_subpath_auto_created(self, mock_docker):
1172+
"""Host volume with non-existent subPath should be auto-created."""
11721173
mock_client = MagicMock()
11731174
mock_client.containers.list.return_value = []
1175+
mock_client.api.create_host_config.return_value = {}
1176+
mock_client.api.create_container.return_value = {"Id": "cid"}
1177+
mock_client.containers.get.return_value = MagicMock()
11741178
mock_docker.from_env.return_value = mock_client
11751179

11761180
service = DockerSandboxService(config=_app_config())
11771181

11781182
import tempfile
11791183

11801184
with tempfile.TemporaryDirectory() as tmpdir:
1185+
sub = "auto-created-sub"
11811186
request = CreateSandboxRequest(
11821187
image=ImageSpec(uri="python:3.11"),
11831188
timeout=120,
@@ -1191,16 +1196,31 @@ def test_host_subpath_not_found_rejected(self, mock_docker):
11911196
host=Host(path=tmpdir),
11921197
mount_path="/mnt/work",
11931198
read_only=False,
1194-
sub_path="nonexistent-sub",
1199+
sub_path=sub,
11951200
)
11961201
],
11971202
)
11981203

1199-
with pytest.raises(HTTPException) as exc_info:
1200-
service.create_sandbox(request)
1204+
import os
1205+
1206+
resolved = os.path.join(tmpdir, sub)
1207+
assert not os.path.exists(resolved)
12011208

1202-
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
1203-
assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_FOUND
1209+
# create_sandbox will proceed past volume validation (subpath
1210+
# auto-created) but will fail later during container provisioning
1211+
# (mock doesn't cover the full flow). We only care that the
1212+
# directory was created — NOT that it raised HOST_PATH_CREATE_FAILED.
1213+
try:
1214+
service.create_sandbox(request)
1215+
except HTTPException as e:
1216+
# If it's our own create-failed error, the auto-create didn't
1217+
# work — let the test fail explicitly.
1218+
if e.detail.get("code") == SandboxErrorCodes.HOST_PATH_CREATE_FAILED:
1219+
raise
1220+
except Exception:
1221+
pass # other provisioning errors are expected
1222+
1223+
assert os.path.isdir(resolved)
12041224

12051225
def test_empty_allowlist_permits_any_host_path(self, mock_docker):
12061226
"""Empty allowed_host_paths (default) should permit any valid host path."""

0 commit comments

Comments
 (0)