1818import uuid as _uuid_module
1919from uuid import UUID
2020
21- from fastapi import APIRouter , Depends , File , Form , HTTPException , Response , UploadFile , status
21+ from fastapi import APIRouter , Depends , HTTPException , Request , Response , status
2222from sqlalchemy .ext .asyncio import AsyncSession
23+ from streaming_form_data import StreamingFormDataParser
24+ from streaming_form_data .targets import FileTarget , ValueTarget
2325
2426from app .auth .dependencies import (
2527 get_runner_or_user_auth ,
5153_DEFAULT_RUNNER = "@bgstm/playwright-core@unknown"
5254
5355# ---------------------------------------------------------------------------
54- # Artifact upload constants
56+ # Artifact upload constants and helpers
5557# ---------------------------------------------------------------------------
5658
57- # Read the upload stream in 64 KiB chunks. Tests may monkeypatch this value
58- # to a smaller number to exercise the streaming / partial-write path .
59+ # Kept as a module attribute so existing monkeypatch calls in tests don't fail
60+ # (the streaming implementation uses network-driven chunk sizes, not this value) .
5961_ARTIFACT_CHUNK_SIZE : int = 65_536 # 64 KiB
6062
6163# Content-type allowlist (global). ``artifact_kind.other`` bypasses this check.
@@ -427,16 +429,45 @@ def _safe_unlink(path: str) -> None:
427429 pass
428430
429431
432+ class _SizeLimitExceeded (Exception ):
433+ """Raised inside ``_SizeLimitedFileTarget.on_data_received`` when the
434+ running byte total exceeds ``max_bytes``. The stream loop catches this
435+ sentinel and stops reading immediately — bytes past the limit are never
436+ consumed from the request stream.
437+ """
438+
439+
440+ class _SizeLimitedFileTarget (FileTarget ):
441+ """``FileTarget`` subclass that aborts mid-stream on size-limit violation."""
442+
443+ def __init__ (self , filename : str , * , max_bytes : int ) -> None :
444+ super ().__init__ (filename )
445+ self ._max_bytes = max_bytes
446+ self .size_bytes : int = 0
447+ # Redeclare with an explicit type so mypy can resolve it (FileTarget sets it
448+ # to None in __init__ but the stubs don't expose its type).
449+ self ._fd = None # type: ignore[assignment]
450+
451+ def on_data_received (self , chunk : bytes ) -> None :
452+ self .size_bytes += len (chunk )
453+ if self .size_bytes > self ._max_bytes :
454+ # Close the file descriptor before aborting so the caller can safely
455+ # unlink the temp file on all platforms.
456+ fd = self ._fd # type: ignore[has-type]
457+ if fd is not None :
458+ fd .close ()
459+ self ._fd = None # type: ignore[has-type]
460+ raise _SizeLimitExceeded ()
461+ super ().on_data_received (chunk )
462+
463+
430464@router .post (
431465 "/external-results/artifact" ,
432466 response_model = ArtifactResponse ,
433467 status_code = status .HTTP_201_CREATED ,
434468)
435469async def upload_artifact (
436- case_result_id : str = Form (...),
437- kind : str = Form (...),
438- filename : str = Form (...),
439- file : UploadFile = File (...),
470+ request : Request ,
440471 db : AsyncSession = Depends (get_db ),
441472 token : RunnerToken = Depends (require_runner_scope (_WRITE_SCOPE )),
442473) -> ArtifactResponse :
@@ -448,11 +479,75 @@ async def upload_artifact(
448479 - ``filename`` — original filename including extension.
449480 - ``file`` — binary body; its ``Content-Type`` part header is used as the
450481 artifact content-type.
482+
483+ The handler uses ``streaming-form-data`` to parse the multipart body chunk by
484+ chunk. As soon as the cumulative byte count of the ``file`` part exceeds
485+ ``BGSTM_ARTIFACT_MAX_BYTES``, a ``_SizeLimitExceeded`` sentinel is raised
486+ inside the part-data callback, the stream loop exits immediately (no further
487+ reads), the temp file is deleted, and 413 is returned.
451488 """
489+ max_bytes : int = settings .BGSTM_ARTIFACT_MAX_BYTES
490+
491+ # Create the temp file upfront; ``FileTarget`` will reopen it via ``on_start``.
492+ fd , tmp_path = tempfile .mkstemp (prefix = "bgstm_artifact_" )
493+ os .close (fd )
494+
495+ case_result_id_target = ValueTarget ()
496+ kind_target = ValueTarget ()
497+ filename_target = ValueTarget ()
498+ file_target = _SizeLimitedFileTarget (tmp_path , max_bytes = max_bytes )
499+
500+ parser = StreamingFormDataParser (headers = request .headers )
501+ parser .register ("case_result_id" , case_result_id_target )
502+ parser .register ("kind" , kind_target )
503+ parser .register ("filename" , filename_target )
504+ parser .register ("file" , file_target )
505+
506+ try :
507+ async for chunk in request .stream ():
508+ parser .data_received (chunk )
509+ except _SizeLimitExceeded :
510+ _safe_unlink (tmp_path )
511+ raise HTTPException (
512+ status_code = status .HTTP_413_REQUEST_ENTITY_TOO_LARGE ,
513+ detail = {
514+ "code" : "artifact.too_large" ,
515+ "message" : f"Artifact exceeds the maximum allowed size of { max_bytes } bytes." ,
516+ "details" : None ,
517+ },
518+ )
519+ except Exception as exc :
520+ _safe_unlink (tmp_path )
521+ raise HTTPException (
522+ status_code = status .HTTP_422_UNPROCESSABLE_ENTITY ,
523+ detail = {
524+ "code" : "validation_error" ,
525+ "message" : f"Failed to parse multipart body: { exc } " ,
526+ "details" : None ,
527+ },
528+ ) from exc
529+
530+ # --- Decode text fields ---
531+ try :
532+ case_result_id = case_result_id_target .value .decode ("utf-8" )
533+ kind = kind_target .value .decode ("utf-8" )
534+ filename = filename_target .value .decode ("utf-8" )
535+ except UnicodeDecodeError as exc :
536+ _safe_unlink (tmp_path )
537+ raise HTTPException (
538+ status_code = status .HTTP_422_UNPROCESSABLE_ENTITY ,
539+ detail = {
540+ "code" : "validation_error" ,
541+ "message" : f"Multipart text field contains invalid UTF-8: { exc } " ,
542+ "details" : None ,
543+ },
544+ ) from exc
545+
452546 # --- Validate kind ---
453547 try :
454548 artifact_kind = ArtifactKind (kind )
455549 except ValueError :
550+ _safe_unlink (tmp_path )
456551 raise HTTPException (
457552 status_code = status .HTTP_422_UNPROCESSABLE_ENTITY ,
458553 detail = {
@@ -463,11 +558,9 @@ async def upload_artifact(
463558 )
464559
465560 # --- Sanitize and validate filename (path-traversal defense) ---
466- # Reject if the filename differs from its own basename OR fails the allowlist regex.
467- # This catches directory components (`../`, `subdir/`, `/etc/`) as well as
468- # dangerous characters (null bytes, backslashes, spaces, etc.).
469561 safe_filename = os .path .basename (filename )
470562 if safe_filename != filename or not _SAFE_FILENAME_RE .fullmatch (safe_filename ):
563+ _safe_unlink (tmp_path )
471564 raise HTTPException (
472565 status_code = status .HTTP_422_UNPROCESSABLE_ENTITY ,
473566 detail = {
@@ -484,6 +577,7 @@ async def upload_artifact(
484577 try :
485578 case_result_uuid = _uuid_module .UUID (case_result_id )
486579 except ValueError :
580+ _safe_unlink (tmp_path )
487581 raise HTTPException (
488582 status_code = status .HTTP_422_UNPROCESSABLE_ENTITY ,
489583 detail = {
@@ -496,6 +590,7 @@ async def upload_artifact(
496590 # --- Verify the case result exists ---
497591 case_result = await get_case_result (db , case_result_uuid )
498592 if case_result is None :
593+ _safe_unlink (tmp_path )
499594 raise HTTPException (
500595 status_code = status .HTTP_404_NOT_FOUND ,
501596 detail = {
@@ -506,10 +601,12 @@ async def upload_artifact(
506601 )
507602
508603 # --- Derive content-type from the upload part header ---
509- content_type : str = (file .content_type or "application/octet-stream" ).split (";" )[0 ].strip ().lower ()
604+ raw_ct = getattr (file_target , "multipart_content_type" , None ) or "application/octet-stream"
605+ content_type : str = raw_ct .split (";" )[0 ].strip ().lower ()
510606
511607 # --- Validate content-type (bypass for kind=other) ---
512608 if artifact_kind != ArtifactKind .other and content_type not in _ALLOWED_CONTENT_TYPES :
609+ _safe_unlink (tmp_path )
513610 raise HTTPException (
514611 status_code = status .HTTP_415_UNSUPPORTED_MEDIA_TYPE ,
515612 detail = {
@@ -522,35 +619,6 @@ async def upload_artifact(
522619 },
523620 )
524621
525- # --- Stream to a temp file, enforcing max size ---
526- max_bytes : int = settings .BGSTM_ARTIFACT_MAX_BYTES
527- fd , tmp_path = tempfile .mkstemp (prefix = "bgstm_artifact_" )
528- total_bytes = 0
529- try :
530- with os .fdopen (fd , "wb" ) as fp :
531- while True :
532- chunk = await file .read (_ARTIFACT_CHUNK_SIZE )
533- if not chunk :
534- break
535- fp .write (chunk )
536- total_bytes += len (chunk )
537- if total_bytes > max_bytes :
538- # Partial data written — clean up and reject
539- raise HTTPException (
540- status_code = status .HTTP_413_REQUEST_ENTITY_TOO_LARGE ,
541- detail = {
542- "code" : "artifact.too_large" ,
543- "message" : (f"Artifact exceeds the maximum allowed size of { max_bytes } bytes." ),
544- "details" : None ,
545- },
546- )
547- except HTTPException :
548- _safe_unlink (tmp_path )
549- raise
550- except Exception :
551- _safe_unlink (tmp_path )
552- raise
553-
554622 # --- Persist via storage backend ---
555623 storage = get_storage ()
556624 storage_key = f"{ case_result_id } /{ _uuid_module .uuid4 ().hex } /{ safe_filename } "
0 commit comments