mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-20 23:21:06 +00:00
feat(sandbox) Adds download file interface in Sandbox (#3038)
* Add download interface in Sandbox * fix * fix * del invalidate test * fix * safe download * improve
This commit is contained in:
@@ -233,3 +233,88 @@ class TestConcurrentFileWrites:
|
||||
thread.join()
|
||||
|
||||
assert storage["content"] in {"seed\nA\nB\n", "seed\nB\nA\n"}
|
||||
|
||||
|
||||
class TestDownloadFile:
|
||||
"""Tests for AioSandbox.download_file."""
|
||||
|
||||
def test_returns_concatenated_bytes(self, sandbox):
|
||||
"""download_file should join chunks from the client iterator into bytes."""
|
||||
sandbox._client.file.download_file = MagicMock(return_value=[b"hel", b"lo"])
|
||||
|
||||
result = sandbox.download_file("/mnt/user-data/outputs/file.bin")
|
||||
|
||||
assert result == b"hello"
|
||||
sandbox._client.file.download_file.assert_called_once_with(path="/mnt/user-data/outputs/file.bin")
|
||||
|
||||
def test_returns_empty_bytes_for_empty_file(self, sandbox):
|
||||
"""download_file should return b'' when the iterator yields nothing."""
|
||||
sandbox._client.file.download_file = MagicMock(return_value=iter([]))
|
||||
|
||||
result = sandbox.download_file("/mnt/user-data/outputs/empty.bin")
|
||||
|
||||
assert result == b""
|
||||
|
||||
def test_uses_lock_during_download(self, sandbox):
|
||||
"""download_file should hold the lock while calling the client."""
|
||||
lock_was_held = []
|
||||
|
||||
def tracking_download(path):
|
||||
lock_was_held.append(sandbox._lock.locked())
|
||||
return iter([b"data"])
|
||||
|
||||
sandbox._client.file.download_file = tracking_download
|
||||
|
||||
sandbox.download_file("/mnt/user-data/outputs/file.bin")
|
||||
|
||||
assert lock_was_held == [True], "download_file must hold the lock during client call"
|
||||
|
||||
def test_raises_oserror_on_client_error(self, sandbox):
|
||||
"""download_file should wrap client exceptions as OSError."""
|
||||
sandbox._client.file.download_file = MagicMock(side_effect=RuntimeError("network error"))
|
||||
|
||||
with pytest.raises(OSError, match="network error"):
|
||||
sandbox.download_file("/mnt/user-data/outputs/file.bin")
|
||||
|
||||
def test_preserves_oserror_from_client(self, sandbox):
|
||||
"""OSError raised by the client should propagate without re-wrapping."""
|
||||
sandbox._client.file.download_file = MagicMock(side_effect=OSError("disk error"))
|
||||
|
||||
with pytest.raises(OSError, match="disk error"):
|
||||
sandbox.download_file("/mnt/user-data/outputs/file.bin")
|
||||
|
||||
def test_rejects_path_outside_virtual_prefix_and_logs_error(self, sandbox, caplog):
|
||||
"""download_file must reject downloads outside /mnt/user-data and log the reason."""
|
||||
sandbox._client.file.download_file = MagicMock()
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
with pytest.raises(PermissionError, match="must be under"):
|
||||
sandbox.download_file("/etc/passwd")
|
||||
|
||||
assert "outside allowed directory" in caplog.text
|
||||
sandbox._client.file.download_file.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/mnt/workspace/../../etc/passwd",
|
||||
"../secret",
|
||||
"/a/b/../../../etc/shadow",
|
||||
],
|
||||
)
|
||||
def test_rejects_path_traversal(self, sandbox, path):
|
||||
"""download_file must reject paths containing '..' before calling the client."""
|
||||
sandbox._client.file.download_file = MagicMock()
|
||||
|
||||
with pytest.raises(PermissionError, match="path traversal"):
|
||||
sandbox.download_file(path)
|
||||
|
||||
sandbox._client.file.download_file.assert_not_called()
|
||||
|
||||
def test_single_chunk(self, sandbox):
|
||||
"""download_file should work correctly with a single-chunk response."""
|
||||
sandbox._client.file.download_file = MagicMock(return_value=[b"single-chunk"])
|
||||
|
||||
result = sandbox.download_file("/mnt/user-data/outputs/single.bin")
|
||||
|
||||
assert result == b"single-chunk"
|
||||
|
||||
@@ -204,6 +204,26 @@ class TestSymlinkEscapes:
|
||||
|
||||
assert exc_info.value.errno == errno.EACCES
|
||||
|
||||
def test_download_file_blocks_symlink_escape_from_mount(self, tmp_path):
|
||||
mount_dir = tmp_path / "mount"
|
||||
mount_dir.mkdir()
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "secret.bin").write_bytes(b"\x00secret")
|
||||
_symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True)
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[
|
||||
PathMapping(container_path="/mnt/user-data", local_path=str(mount_dir), read_only=False),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError) as exc_info:
|
||||
sandbox.download_file("/mnt/user-data/escape/secret.bin")
|
||||
|
||||
assert exc_info.value.errno == errno.EACCES
|
||||
|
||||
def test_write_file_blocks_symlink_escape_from_mount(self, tmp_path):
|
||||
mount_dir = tmp_path / "mount"
|
||||
mount_dir.mkdir()
|
||||
@@ -334,6 +354,74 @@ class TestSymlinkEscapes:
|
||||
assert existing.read_bytes() == b"original"
|
||||
|
||||
|
||||
class TestDownloadFileMappings:
|
||||
"""download_file must use _resolve_path_with_mapping so path resolution, symlink
|
||||
containment, and read-only awareness are consistent with read_file."""
|
||||
|
||||
def test_resolves_container_path_via_mapping(self, tmp_path):
|
||||
"""download_file should resolve container paths through path mappings."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "asset.bin").write_bytes(b"\x01\x02\x03")
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[PathMapping(container_path="/mnt/user-data", local_path=str(data_dir))],
|
||||
)
|
||||
|
||||
result = sandbox.download_file("/mnt/user-data/asset.bin")
|
||||
|
||||
assert result == b"\x01\x02\x03"
|
||||
|
||||
def test_raises_oserror_with_original_path_when_missing(self, tmp_path):
|
||||
"""OSError filename should show the container path, not the resolved host path."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[PathMapping(container_path="/mnt/user-data", local_path=str(data_dir))],
|
||||
)
|
||||
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
sandbox.download_file("/mnt/user-data/missing.bin")
|
||||
|
||||
assert exc_info.value.filename == "/mnt/user-data/missing.bin"
|
||||
|
||||
def test_rejects_path_outside_virtual_prefix_and_logs_error(self, tmp_path, caplog):
|
||||
"""download_file must reject paths outside /mnt/user-data and log the reason."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "model.bin").write_bytes(b"weights")
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[PathMapping(container_path="/mnt/user-data", local_path=str(data_dir), read_only=True)],
|
||||
)
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
with pytest.raises(PermissionError) as exc_info:
|
||||
sandbox.download_file("/mnt/skills/model.bin")
|
||||
|
||||
assert exc_info.value.errno == errno.EACCES
|
||||
assert "outside allowed directory" in caplog.text
|
||||
|
||||
def test_readable_from_read_only_mount(self, tmp_path):
|
||||
"""Read-only mounts must not block download_file — read-only only restricts writes."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
(skills_dir / "model.bin").write_bytes(b"weights")
|
||||
|
||||
sandbox = LocalSandbox(
|
||||
"test",
|
||||
[PathMapping(container_path="/mnt/user-data", local_path=str(skills_dir), read_only=True)],
|
||||
)
|
||||
|
||||
result = sandbox.download_file("/mnt/user-data/model.bin")
|
||||
|
||||
assert result == b"weights"
|
||||
|
||||
|
||||
class TestMultipleMounts:
|
||||
def test_multiple_read_write_mounts(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
|
||||
Reference in New Issue
Block a user