From ea337338e33a289acfe4684b63078cdcc32d1e19 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 13:24:17 +0100 Subject: [PATCH] feat: download - download safeguard --- .../file_ops_service.cpython-313.pyc | Bin 32021 -> 37820 bytes .../backend/app/services/file_ops_service.py | 170 +++++++++++++++--- .../test_api_download_golden.cpython-313.pyc | Bin 12924 -> 18744 bytes .../tests/golden/test_api_download_golden.py | 114 ++++++++++-- 4 files changed, 248 insertions(+), 36 deletions(-) diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 9f2333566238579caf60208529d2339fc5570e53..9aa3599999595712afe2cc3e56e7b0b550dc30bb 100644 GIT binary patch delta 12497 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 T7P!jyb-j6f%MA`8lg|GE5P=w_ delta 7189 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 sz4Q<=usiHX>b$ z(1cKe(9GWcVoeI-x)YyZOJ(BDsXh#|2Y4{vksaTkrTBOOj+4GTks07Y_moJ)D3?LQ zLE!UQ%@dr$>2W+oiwuF>OTLmPz<-p(sgfJ#(?Z?Av*!EoHRp_}T5=2mdyr45TcTad?nGtK|gsTWT!mkjvBfw#nxtYBv2qgmj z9zJ8qrA-JwWiMH)TYiH~EDOeoT#Xb0-i~TN2DyiY-K(fB%c8h_%M*dcT{! zd8VR=E1{^=sJlX;@foNYN+?9({DhlG(9}(8f?DvY9^Yql6Yz+AOmZ@&9;S8{Ivapq z>A|x#b-2eU?g+kP`DHT2y9K_tsF(fi*`=hI{Ti@@InFhwcp-QK1^8XXTS)yD;XQ=^ zLHHfQhX~xQd{=eH2DpQmi7DMQ3g*E-L7NA-X(oicAvD|&oNox_uM3553QOM*D*i)Q h{((s#Bc=~PcEt29d->cm#UHrtca!Ds35Yz1{}1(kXG#D7 diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index ef44c03..0b399d8 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -1,10 +1,13 @@ from __future__ import annotations import os -from io import BytesIO +import time import zipfile +from dataclasses import dataclass from datetime import datetime, timezone +from io import BytesIO from pathlib import Path +from typing import Callable from backend.app.api.errors import AppError from backend.app.api.schemas import DeleteResponse, FileInfoResponse, MkdirResponse, RenameResponse, SaveResponse, UploadResponse, ViewResponse @@ -54,11 +57,37 @@ PDF_CONTENT_TYPES = { } +@dataclass(frozen=True) +class ZipDownloadPreflightLimits: + max_items: int = 1000 + max_total_input_bytes: int = 2 * 1024 * 1024 * 1024 + max_individual_file_bytes: int = 500 * 1024 * 1024 + scan_timeout_seconds: float = 10.0 + + +@dataclass +class ZipDownloadPreflightState: + item_count: int = 0 + total_input_bytes: int = 0 + + +ZIP_DOWNLOAD_PREFLIGHT_LIMITS = ZipDownloadPreflightLimits() + + class FileOpsService: - def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter, history_repository: HistoryRepository | None = None): + def __init__( + self, + path_guard: PathGuard, + filesystem: FilesystemAdapter, + history_repository: HistoryRepository | None = None, + zip_download_preflight_limits: ZipDownloadPreflightLimits = ZIP_DOWNLOAD_PREFLIGHT_LIMITS, + monotonic: Callable[[], float] | None = None, + ): self._path_guard = path_guard self._filesystem = filesystem self._history_repository = history_repository + self._zip_download_preflight_limits = zip_download_preflight_limits + self._monotonic = monotonic or time.monotonic def mkdir(self, parent_path: str, name: str) -> MkdirResponse: try: @@ -673,7 +702,6 @@ class FileOpsService: def _prepare_zip_download(self, resolved_targets: list) -> dict: archive_names: set[str] = set() for resolved_target in resolved_targets: - self._validate_download_target(resolved_target) archive_name = resolved_target.absolute.name if archive_name in archive_names: raise AppError( @@ -682,6 +710,7 @@ class FileOpsService: status_code=400, ) archive_names.add(archive_name) + self._run_zip_download_preflight(resolved_targets) if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir(): download_name = f"{resolved_targets[0].absolute.name}.zip" @@ -705,37 +734,126 @@ class FileOpsService: "content_type": "application/zip", } - def _validate_download_target(self, resolved_target) -> None: + def _run_zip_download_preflight(self, resolved_targets: list) -> None: + started_at = self._monotonic() + state = ZipDownloadPreflightState() + for resolved_target in resolved_targets: + self._ensure_zip_download_preflight_within_timeout(started_at) + self._validate_zip_download_root_target(resolved_target) + if resolved_target.absolute.is_file(): + self._record_zip_download_file( + state=state, + entry_path=resolved_target.absolute, + entry_relative=resolved_target.relative, + ) + continue + self._increment_zip_download_item_count( + state=state, + entry_relative=resolved_target.relative, + ) + self._scan_zip_download_directory( + state=state, + resolved_target=resolved_target, + started_at=started_at, + ) + + def _validate_zip_download_root_target(self, resolved_target) -> None: _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative) if lexical_source.is_symlink(): - raise AppError( - code="type_conflict", - message="Source must not be a symlink", - status_code=409, + self._raise_zip_download_preflight_error( + reason="symlink_detected", details={"path": resolved_target.relative}, ) - if resolved_target.absolute.is_file(): + if resolved_target.absolute.is_file() or resolved_target.absolute.is_dir(): return - if resolved_target.absolute.is_dir(): - for root, dirnames, filenames in os.walk(resolved_target.absolute, followlinks=False): - root_path = Path(root) - for name in [*dirnames, *filenames]: - entry = root_path / name - if entry.is_symlink(): - raise AppError( - code="type_conflict", - message="Source directory must not contain symlinks", - status_code=409, - details={"path": resolved_target.relative}, - ) - return - raise AppError( - code="type_conflict", - message="Unsupported path type for download", - status_code=409, + self._raise_zip_download_preflight_error( + reason="unsupported_path_type", details={"path": resolved_target.relative}, ) + def _scan_zip_download_directory(self, state: ZipDownloadPreflightState, resolved_target, started_at: float) -> None: + for root, dirnames, filenames in os.walk(resolved_target.absolute, followlinks=False): + root_path = Path(root) + dirnames.sort() + filenames.sort() + for name in [*dirnames, *filenames]: + self._ensure_zip_download_preflight_within_timeout(started_at) + entry_path = root_path / name + relative_suffix = entry_path.relative_to(resolved_target.absolute).as_posix() + entry_relative = self._join_relative(resolved_target.relative, relative_suffix) + if entry_path.is_symlink(): + self._raise_zip_download_preflight_error( + reason="symlink_detected", + details={"path": entry_relative}, + ) + if entry_path.is_dir(): + self._increment_zip_download_item_count(state=state, entry_relative=entry_relative) + continue + self._record_zip_download_file( + state=state, + entry_path=entry_path, + entry_relative=entry_relative, + ) + + def _record_zip_download_file( + self, + *, + state: ZipDownloadPreflightState, + entry_path: Path, + entry_relative: str, + ) -> None: + self._increment_zip_download_item_count(state=state, entry_relative=entry_relative) + file_size = int(entry_path.stat().st_size) + if file_size > self._zip_download_preflight_limits.max_individual_file_bytes: + self._raise_zip_download_preflight_error( + reason="max_individual_file_size_exceeded", + details={ + "path": entry_relative, + "limit_bytes": str(self._zip_download_preflight_limits.max_individual_file_bytes), + "actual_bytes": str(file_size), + }, + ) + state.total_input_bytes += file_size + if state.total_input_bytes > self._zip_download_preflight_limits.max_total_input_bytes: + self._raise_zip_download_preflight_error( + reason="max_total_input_bytes_exceeded", + details={ + "limit_bytes": str(self._zip_download_preflight_limits.max_total_input_bytes), + "actual_bytes": str(state.total_input_bytes), + }, + ) + + def _increment_zip_download_item_count(self, *, state: ZipDownloadPreflightState, entry_relative: str) -> None: + state.item_count += 1 + if state.item_count > self._zip_download_preflight_limits.max_items: + self._raise_zip_download_preflight_error( + reason="max_items_exceeded", + details={ + "path": entry_relative, + "limit": str(self._zip_download_preflight_limits.max_items), + "actual": str(state.item_count), + }, + ) + + def _ensure_zip_download_preflight_within_timeout(self, started_at: float) -> None: + elapsed = self._monotonic() - started_at + if elapsed > self._zip_download_preflight_limits.scan_timeout_seconds: + self._raise_zip_download_preflight_error( + reason="preflight_timeout", + details={ + "timeout_seconds": str(self._zip_download_preflight_limits.scan_timeout_seconds), + }, + ) + + @staticmethod + def _raise_zip_download_preflight_error(reason: str, details: dict[str, str]) -> None: + raise AppError( + code="download_preflight_failed", + message="Zip download preflight failed", + status_code=409, + details={"reason": reason, **details}, + ) + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: root_name = resolved_target.absolute.name if resolved_target.absolute.is_file(): diff --git a/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc index 82594abf09709632848762c81e4f32a548f598eb..d6c6b74d2a013439a01bda7c43bd56e7989b9f70 100644 GIT binary patch delta 6182 zcmbtYdvFuS8NZWG(n<0|mMqzljVY20+ar2l-Cv7_IG}Ckv9AI#72;`r(!?e?w!EI9DSRx$VMU=xab?`{?9(7nvY7wJ5L#F%YZ@dfdPjCYq;U$ik zB%YWhpJWPfLjn;%Gc(O1l}Q5Atda%(tZk+$u8K=laZzWN1g(H+1)vo&tq`;#rWJu!%(PHIr(A-ROgI3{Tst43krj<%w z;vKYv{JNK(wtDGRQ-v_ZbKJ}3D!#3ci`g?v(C^J?ooNMXotd&eZnqzHAYcu0gIvf$ z9}qhDLONsV=z|U5bj$G3s7eT$>t-!UbjL_Ic|;x@4U;GlvD9%Gc10W0lFG1NA}5Y1 zgs4$P){IS>;B`S$Vuv;Gj0@^(=t|3N^g(Nr@D+HcuUfZs1!{lpd8zlceP{PgR{cRb z*VA{YXa7{s{&x;e)eW2v95^LBQhYhkaz4S%cy> z5JvO+f|=%nN>UGCbBhdWE&aW-ZdX0nbx)XdYmDJnw+|=ciDV+KMk2U!_?Y0|2LIp; zf8zyE4hm0j{h3-0HWDq;553)7mFnX+@J;E0D6CPC>&Fh*lf?p@gDzoMkj#Cs^XxQ; zLqgo#XYR-CWzTMQmNRe0Z+}7Ny%7|Ej?f(v$THAL6$pLVxEdUhqv_)*k7UlLY)p`B z1|xc2XkJIK{R9`{bz2%UY-iGo9msB_j5w1MBU%Pp`rWvO3n53JvDBq*hfXy_?agc4 zNp9K`IMw|xO@Q}rn5}%sM7lsFIE9dn{p36+g;<3%Cb)J4R}3IlgZd^E=sEc!itQjY z3y2^*o7nb}YIxNf(X(*Q_4LGA(0&_;nU1je)7*ZkdDn%G?(-eH zrhV1d?G}&Yl`5psU)J zTce4HM!M;H?t1G^tlC9CaaV7{0Z0Xk9uyl;>_#D>=tHpw1&*lO#?=wrDcuI_jD$^a z)k!Zs=<)4fiwNWnW?>RJq8?MyM=O0c>XV9=%z;KzoDNpcylimJDg?bOv=mN}cH9>F5 z5Q?n7RPN6dEj&2<} z0)(XL);R2u6740&>91?0g|5}tH5jfjurS!nL(Ko<+7Ig<=Eo-6>$mbw3~-DKlO~#K z2+}rJnC|tK@;q&=sWA(Zh4y$WY<#Pe<0O%`Rru|82}-+6L5`mF`YQXg5mItUPWUU3 z3cCcUsEY>!xzSf36?gJ520U?|UMcXMsq~p77yYoJmM@`a8!HM+@a#)&COr$vOIf)$ zSDrHLeXP8U$@0!LXS3+*?~KU_=F5x zFs$ibRFjEhI3}y{kE#kCIBy@gtbImcW0*|D<>j~5>=uhJDG=hBwK^8=R!;uhMNxpjLki1*U_LuHQ8lz;uXjR!7> z2l2H9Utixl<==j8$G}DL0I7%JvPlByT=ro6fPSr^2F^t(z1zD#kc<72O%f$Lq8{*G z(8W`iyBxruhjzKK?2ONAl8We6Z!LYPv9hq3;nl@tNv1Sql%`Jrt1F#@Xm2+Ivo36g zXCAld!WMY0VWho=L6&?I#kWvAfZ|~k!zdm>@of~3qIe9&X%wk5AoZ%bFi)dA2OH6V z4dbJw5JSF${XGE!k&*?<##;krxq+125t~1@-ixS7EsmO{n>~wuyaxhO7klAj{s;Y! z-#cZmyd*YFiA_KDz20~ZwJo=-%Vf#`xaR3V<#a>i(ujMq>vY$-W&6*2`svP=P9cN5 ze5f6{D5|%B+NWu#?J;%obZ1#rKDz?xN>;=ipv|6RS4SS+GVJQiv1MTa!&4!XMY(d1 zQ9c)sSx!#n*QRkf2a%-)$Bx@$VKS(+9a7^O(&(zyU+CyuL!JVa;qV#`J0Fb5e@lar zVKG&+7`JNbxRS-AF5oBBc$o$%4N&8%m6&y{%(|1$a=Oz<>lin))N(Pl%f~L}r5Tp% zx;1#?5ESNyfMg;eGe7j52-{T)VY_6DvoNmqclvKnFNiUuzZI-(&y;`bx$W{r@xFza#GPY#foPgcHAH!O&09u)+E_Oa zP*=S*SULwVt~pRC=^hs$8pVuodm@j%FiMY$F*SY^>&WN8g-aV7Mtd3J9E&sLdF;cd zsdG_~?lP3mS(roVj!Xw&KR4L1?Z-?X&8$g;b&{}{2de;JNXNWZ`fgJzeYI(u^*2zg z{h9KiC-b6A{#x(9Aok;0x96?(!Hc52aILeU=55Fd4w~rK8>-UrW^14yH-bGYImy<; zXO8=@3Dd8Jyz~o$Q92y;ncyQ7Z%jl;#xPT3iC9slZC)Z)oTG%obXa0k;m(z(jB*bv zPp4yZcGzcxr^PdGa6Kdprl*ieu^Kj!pFxo!vTTtfib%#De|vEQAj{`(1VGuy=YOvE za+x{RaqJTqK7fTde7?c2C8eKz#pE!h-CFJiZ7 zaKj@XPK?H*6T)I=dSNDa{dy{jGkz$ZJB$KW>2;C^VjLeZ9S=aFoqi+ zyzDV9Pu!^CJzM&EMHjjgaRp4+MOY=)vRjw6XdaJbO}l;9?Ji3%50^D1 zt7mLXb#RWNe>hQ{t)uJPD^ecIL4LN`Wf_8k5=)q$ZE#xd<7WeI%K?72Nw9p0pDhq9 zJ^aTY_wa@rI5^IIIIPBHIaH%NWclzY+&02W5Ij9(9a@;-7M7GT+tIk1#Dm1X`*o9= zVD}TcZG^trUf@}UO`b}1G84y%r`dyEXQ7Fw%TZTb(w%G`#*#V B9TETl delta 2484 zcmbtVYitx%6rMXfv#)Nq+tNb2TiO*UbsNjemQp}VUr+?fW6R5;N>Z@2JE>%i%q-0T4N``i+TbA(;T4q#v z$WxMF_O6;_@$wy#q?bt&KE0nW_LuXQ*};QMgjbsxW8;XOG+@k38L7gS0?LIycJp~8 zC!}2xu%rZXl7S}~Ny<@aj{*uZkIU3#X;N{S7j3G`e8{{m^CR=QEP%}KvfxUj0hbmb z3%V?X4AVgAVq_tgg^?AztRxwR$Y4pLOjPjq7gg|fPt1FmNz%if{3D(kHe|8>8Ivh& zW&IVCy!kdss;zR?lk{!%G%+_a{~pDXx61u9Cq1`RPO_>0{^%sHM=~fS{QR=gR3(ex zi0%w&gQGg6MQ{KsY45ikBg>mY4NJyZ@3@p;!e{qFfFGB~eX$1ck-H)k+sF(6r4O?S zVhFB!JRV;K)wtpp)P#Bz$+Sw_mN; zaU^)HEPm>NPdfXqc5b^;w*83jmcl7uvSi`?5uT797 z%p>R_Xd_rl@BqPjf(-;Mys;v-Y#mbJxAd%$F>OaEVCd;_JefQ;(leh9sUdUBC}a}H zOvqZt&$d>UIQvHoEtRtdGG@vVE3kLw@poH;Gp5w}rb#xds9JkA-w?QK|cOyU1+UB+{a#d5;0el3Hl2F(k#a7PI26CfTdR)2VTzDM4#^tFyWy@%wo5Ta{TiG*Q zYu}7B;avM~i>5@&OlwTfm}w1iD2zThC}|!!c6W9{(bT8$~VuqpOyG+ttE2uj$^rlxAN-E>w9A@?KofuoFse z&N328Vb;~5#Wll9@-O9IcQ0LbHyIZYxb3xx*uw-v1UZ5+f)FIiMcu$ALW;VX-9p^P z3RU38JY$(tKmNK@Zi@J;;!$gWjYStC-;5|+03~EMfj+y z8FM#Kk3$o=Dr6GgxM|Tq0xAg|MIUEP6FxBPTn67k89%!w-z@xnDB7u`tDD+!SAoKU z>6r`v%4{w@nlbK&Q~b4_!RqVMstIqq%(k(UJ)c!KUa4-Jklf{uJqn+_c9ud#`xA1* BPiO!D diff --git a/webui/backend/tests/golden/test_api_download_golden.py b/webui/backend/tests/golden/test_api_download_golden.py index 6e749ea..8a0159f 100644 --- a/webui/backend/tests/golden/test_api_download_golden.py +++ b/webui/backend/tests/golden/test_api_download_golden.py @@ -16,7 +16,7 @@ from backend.app.dependencies import get_file_ops_service from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.main import app from backend.app.security.path_guard import PathGuard -from backend.app.services.file_ops_service import FileOpsService +from backend.app.services.file_ops_service import FileOpsService, ZipDownloadPreflightLimits class DownloadApiGoldenTest(unittest.TestCase): @@ -24,13 +24,9 @@ class DownloadApiGoldenTest(unittest.TestCase): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) / "root" self.root.mkdir(parents=True, exist_ok=True) - path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) - service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) - - async def _override_file_ops_service() -> FileOpsService: - return service - - app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + self.path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + self.filesystem = FilesystemAdapter() + self._override_service() def tearDown(self) -> None: app.dependency_overrides.clear() @@ -44,6 +40,24 @@ class DownloadApiGoldenTest(unittest.TestCase): return asyncio.run(_run()) + def _override_service( + self, + *, + limits: ZipDownloadPreflightLimits | None = None, + monotonic=None, + ) -> None: + service = FileOpsService( + path_guard=self.path_guard, + filesystem=self.filesystem, + zip_download_preflight_limits=limits or ZipDownloadPreflightLimits(), + monotonic=monotonic, + ) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + def test_download_success_for_allowed_file(self) -> None: src = self.root / "report.txt" src.write_text("hello download", encoding="utf-8") @@ -55,7 +69,7 @@ class DownloadApiGoldenTest(unittest.TestCase): self.assertIn('attachment; filename="report.txt"', response.headers.get("content-disposition", "")) self.assertEqual(response.headers.get("content-type"), "text/plain; charset=utf-8") - def test_download_directory_type_conflict(self) -> None: + def test_download_single_directory_as_zip(self) -> None: (self.root / "docs").mkdir() (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") @@ -121,6 +135,64 @@ class DownloadApiGoldenTest(unittest.TestCase): self.assertIn("photos/", archive.namelist()) self.assertIn("photos/nested/img.txt", archive.namelist()) + def test_download_zip_rejected_when_max_items_exceeded(self) -> None: + (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("A", encoding="utf-8") + (self.root / "docs" / "b.txt").write_text("B", encoding="utf-8") + (self.root / "docs" / "c.txt").write_text("C", encoding="utf-8") + self._override_service( + limits=ZipDownloadPreflightLimits( + max_items=3, + max_total_input_bytes=1024, + max_individual_file_bytes=1024, + scan_timeout_seconds=10.0, + ) + ) + + response = self._get("/api/files/download?path=storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") + self.assertEqual(response.json()["error"]["message"], "Zip download preflight failed") + self.assertEqual(response.json()["error"]["details"]["reason"], "max_items_exceeded") + + def test_download_zip_rejected_when_max_total_input_bytes_exceeded(self) -> None: + (self.root / "a.txt").write_text("AAAA", encoding="utf-8") + (self.root / "b.txt").write_text("BBBB", encoding="utf-8") + self._override_service( + limits=ZipDownloadPreflightLimits( + max_items=10, + max_total_input_bytes=7, + max_individual_file_bytes=1024, + scan_timeout_seconds=10.0, + ) + ) + + response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") + self.assertEqual(response.json()["error"]["details"]["reason"], "max_total_input_bytes_exceeded") + + def test_download_zip_rejected_when_individual_file_too_large(self) -> None: + (self.root / "docs").mkdir() + (self.root / "docs" / "large.bin").write_bytes(b"123456") + self._override_service( + limits=ZipDownloadPreflightLimits( + max_items=10, + max_total_input_bytes=1024, + max_individual_file_bytes=5, + scan_timeout_seconds=10.0, + ) + ) + + response = self._get("/api/files/download?path=storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") + self.assertEqual(response.json()["error"]["details"]["reason"], "max_individual_file_size_exceeded") + self.assertEqual(response.json()["error"]["details"]["path"], "storage1/docs/large.bin") + def test_download_directory_with_symlink_rejected(self) -> None: target = self.root / "real.txt" target.write_text("x", encoding="utf-8") @@ -130,7 +202,29 @@ class DownloadApiGoldenTest(unittest.TestCase): response = self._get("/api/files/download?path=storage1/docs") self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "type_conflict") + self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") + self.assertEqual(response.json()["error"]["details"]["reason"], "symlink_detected") + self.assertEqual(response.json()["error"]["details"]["path"], "storage1/docs/link.txt") + + def test_download_zip_preflight_timeout_rejected_cleanly(self) -> None: + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + ticks = iter([0.0, 11.0, 11.0, 11.0]) + self._override_service( + limits=ZipDownloadPreflightLimits( + max_items=10, + max_total_input_bytes=1024, + max_individual_file_bytes=1024, + scan_timeout_seconds=10.0, + ), + monotonic=lambda: next(ticks), + ) + + response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") + self.assertEqual(response.json()["error"]["details"]["reason"], "preflight_timeout") def test_download_path_not_found(self) -> None: response = self._get("/api/files/download?path=storage1/missing.txt")