diff --git a/backend/agent_app.py b/backend/agent_app.py index 755a0b4..0abe167 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -33,6 +33,7 @@ from fastapi import ( WebSocketDisconnect, ) from fastapi.responses import FileResponse, JSONResponse +from starlette.background import BackgroundTask from pydantic import BaseModel from config import settings @@ -666,6 +667,12 @@ def files_read(path: str = Query(...)) -> dict: @app.get("/agent/files/download", dependencies=[Depends(verify_token)]) def files_download(path: str = Query(...)): + if _file_guard(file_service.is_dir, path): + tmp, filename = _file_guard(file_service.archive_dir, path) + return FileResponse( + tmp, filename=filename, media_type="application/zip", + background=BackgroundTask(os.unlink, tmp), + ) real, filename = _file_guard(file_service.resolve_download, path) return FileResponse(real, filename=filename, media_type="application/octet-stream") diff --git a/backend/routers/files.py b/backend/routers/files.py index e703924..ad3319d 100644 --- a/backend/routers/files.py +++ b/backend/routers/files.py @@ -20,6 +20,7 @@ from fastapi import ( UploadFile, ) from fastapi.responses import FileResponse +from starlette.background import BackgroundTask from pydantic import BaseModel from sqlmodel import Session @@ -69,6 +70,12 @@ def download( path: str = Query(...), _user: User = Depends(get_current_user), ): + if _guard(file_service.is_dir, path): + tmp, filename = _guard(file_service.archive_dir, path) + return FileResponse( + tmp, filename=filename, media_type="application/zip", + background=BackgroundTask(os.unlink, tmp), + ) real, filename = _guard(file_service.resolve_download, path) return FileResponse(real, filename=filename, media_type="application/octet-stream") diff --git a/backend/services/file_service.py b/backend/services/file_service.py index 46c774d..6a338b3 100644 --- a/backend/services/file_service.py +++ b/backend/services/file_service.py @@ -10,6 +10,8 @@ from __future__ import annotations import os import shutil +import tempfile +import zipfile from services.device_service import BrowseError, _is_allowed, _real_root @@ -175,6 +177,47 @@ def resolve_download(path: str) -> tuple[str, str]: return real, os.path.basename(path) +def is_dir(path: str) -> bool: + """Whether ``path`` points at a directory inside the sandbox.""" + return os.path.isdir(_safe_real(path)) + + +def archive_dir(path: str) -> tuple[str, str]: + """Zip a directory (recursively) into a temp file. + + Returns ``(tmp_zip_path, download_filename)``. The caller is responsible + for deleting the temp file once it has been streamed to the client. + Symlinks are skipped so the archive cannot escape the sandbox or loop. + """ + real = _safe_real(path) + if not os.path.isdir(real): + raise BrowseError(f"Not a directory: {path}") + name = os.path.basename(path.rstrip("/")) or "root" + + fd, tmp = tempfile.mkstemp(suffix=".zip") + os.close(fd) + try: + with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(real): + # Don't follow symlinked directories (avoids loops / escapes). + dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d))] + rel_root = os.path.relpath(root, real) + if not files and not dirs and rel_root != ".": + # Preserve otherwise-empty directories. + zf.writestr(os.path.join(name, rel_root) + "/", "") + for f in files: + full = os.path.join(root, f) + if os.path.islink(full): + continue + zf.write(full, os.path.join(name, rel_root, f) if rel_root != "." + else os.path.join(name, f)) + except OSError: + if os.path.exists(tmp): + os.unlink(tmp) + raise + return tmp, f"{name}.zip" + + def upload_target( dir_path: str, filename: str, diff --git a/backend/version.py b/backend/version.py index 0cc6368..e8cc1fc 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.36.1" +APP_VERSION = "0.37.0" diff --git a/frontend/package.json b/frontend/package.json index 58fd912..0aa3d3e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.36.1", + "version": "0.37.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx index e0e5862..d6bb86b 100644 --- a/frontend/src/pages/Files.tsx +++ b/frontend/src/pages/Files.tsx @@ -202,7 +202,7 @@ export function Files() { const download = (e: HostPathEntry) => filesApi - .download(join(path, e.name), e.name, host) + .download(join(path, e.name), e.type === "dir" ? `${e.name}.zip` : e.name, host) .catch((err) => toast.error(apiErrorMessage(err))); return ( @@ -407,15 +407,13 @@ export function Files() {