diff --git a/backend/api_v2/deployment_helper.py b/backend/api_v2/deployment_helper.py index 5fbec7999c..38b1a8795f 100644 --- a/backend/api_v2/deployment_helper.py +++ b/backend/api_v2/deployment_helper.py @@ -19,7 +19,9 @@ from utils.constants import Account, CeleryQueue from utils.local_context import StateStore from workflow_manager.endpoint_v2.destination import DestinationConnector +from workflow_manager.endpoint_v2.result_cache_utils import ResultCacheUtils from workflow_manager.endpoint_v2.source import SourceConnector +from workflow_manager.utils.pipeline_utils import PipelineUtils from workflow_manager.workflow_v2.dto import ExecutionResponse from workflow_manager.workflow_v2.enums import ExecutionStatus from workflow_manager.workflow_v2.execution import WorkflowExecutionServiceHelper @@ -293,7 +295,12 @@ def execute_workflow( logger.exception(f"Failed to mark execution {execution_id} as ERROR") # Async job never started — release the rate limit slot and clean up. - APIDeploymentRateLimiter.release_slot(api.organization, str(execution_id)) + # The id string, never the model instance: release_slot builds its + # Redis key from what it is passed, so an instance silently removes + # nothing and leaves the slot held for its full TTL. + APIDeploymentRateLimiter.release_slot( + str(api.organization.organization_id), str(execution_id) + ) DestinationConnector.delete_api_storage_dir( workflow_id=workflow_id, execution_id=execution_id ) @@ -306,6 +313,69 @@ def execute_workflow( ) ).data + # Nothing was staged, so nothing will be dispatched to write a status + # back. Terminalise here or the execution is stranded in PENDING. + if not hash_values_of_files: + # Isolated so the slot and staging dir are released even if it raises. + execution = None + terminalise_error: str | None = None + try: + execution = WorkflowExecutionServiceHelper.update_execution_completed( + str(execution_id), + total_files=len(file_objs), + failed_files=len(file_objs), + ) + except Exception as error: + logger.exception(f"Failed to mark execution {execution_id} as COMPLETED") + # Reported, so the caller is not given a bare failure with no reason. + terminalise_error = str(error) + + APIDeploymentRateLimiter.release_slot( + str(api.organization.organization_id), str(execution_id) + ) + DestinationConnector.delete_api_storage_dir( + workflow_id=workflow_id, execution_id=execution_id + ) + api_results = ResultCacheUtils.get_api_results( + workflow_id=str(workflow_id), execution_id=str(execution_id) + ) + if execution is not None: + # Separate blocks: independent obligations, so a failure in one + # must not silence the other. + try: + # This response carries the results, so mark them consumed or a + # later GET /status serves them again. + WorkflowHelper.set_result_acknowledge(execution) + except Exception: + logger.exception( + f"Failed to acknowledge results for execution {execution_id}" + ) + + try: + # Terminalising here bypasses the usual notifier, so subscribers + # would otherwise hear nothing. + PipelineUtils.update_pipeline_status( + pipeline_id=pipeline_id, workflow_execution=execution + ) + except Exception: + logger.exception( + f"Failed to notify subscribers for execution {execution_id}" + ) + + # The stored status, not an assumed COMPLETED: the row may be missing + # or the guard may have refused the change. + return APIExecutionResponseSerializer( + ExecutionResponse( + workflow_id=workflow_id, + execution_id=execution_id, + execution_status=( + execution.status if execution else ExecutionStatus.ERROR.value + ), + error=terminalise_error, + result=api_results, + ) + ).data + try: result = WorkflowHelper.execute_workflow_async( workflow_id=workflow_id, @@ -352,7 +422,9 @@ def execute_workflow( # Dispatch failures are marked ERROR internally by execute_workflow_async; # post-dispatch failures (enrichment/config) must not overwrite a running # execution's status, so only release the slot and clean up storage here. - APIDeploymentRateLimiter.release_slot(api.organization, str(execution_id)) + APIDeploymentRateLimiter.release_slot( + str(api.organization.organization_id), str(execution_id) + ) # Clean up storage DestinationConnector.delete_api_storage_dir( diff --git a/backend/api_v2/tests/test_deployment_helper.py b/backend/api_v2/tests/test_deployment_helper.py index 39b23e5b16..2e8c3ec7d3 100644 --- a/backend/api_v2/tests/test_deployment_helper.py +++ b/backend/api_v2/tests/test_deployment_helper.py @@ -48,6 +48,7 @@ def _api() -> MagicMock: api = MagicMock() api.workflow.id = "wf-1" api.id = "pipe-1" + api.organization.organization_id = "org-uuid-1" return api @@ -89,3 +90,195 @@ def test_staging_failure_cleanup_survives_db_marking_error(collaborators) -> Non # Cleanup still runs even though error-marking raised. collaborators["APIDeploymentRateLimiter"].release_slot.assert_called_once() collaborators["DestinationConnector"].delete_api_storage_dir.assert_called_once() + + +@pytest.fixture +def staging_rejects_everything(): + """Patch execute_workflow's collaborators; staging returns no dispatchable files.""" + with mock.patch.multiple( + dh, + WorkflowExecutionServiceHelper=mock.DEFAULT, + SourceConnector=mock.DEFAULT, + DestinationConnector=mock.DEFAULT, + APIDeploymentRateLimiter=mock.DEFAULT, + WorkflowHelper=mock.DEFAULT, + ResultCacheUtils=mock.DEFAULT, + PipelineUtils=mock.DEFAULT, + Tag=mock.DEFAULT, + logger=mock.DEFAULT, + ) as mocks: + execution_row = MagicMock() + execution_row.id = "exec-123" + mocks[ + "WorkflowExecutionServiceHelper" + ].create_workflow_execution.return_value = execution_row + mocks["SourceConnector"].add_input_file_to_api_storage.return_value = {} + mocks["ResultCacheUtils"].get_api_results.return_value = [ + {"file": "evil.pdf", "status": "Failed", "error": "unsupported MIME type"} + ] + completed_row = MagicMock() + completed_row.status = "COMPLETED" + mocks[ + "WorkflowExecutionServiceHelper" + ].update_execution_completed.return_value = completed_row + yield mocks + + +def test_all_files_rejected_completes_without_dispatch( + staging_rejects_everything, +) -> None: + """A request whose every file is rejected must reach a terminal status. + + The worker short-circuits an empty file set without writing a status back, so + dispatching one strands the execution in PENDING and the caller polls forever. + """ + mocks = staging_rejects_everything + # A non-empty upload whose staging result is empty. Passing [] instead would + # leave the branch satisfied by `not file_objs` too, and the original bug - + # dispatching a request whose files were all rejected - would pass this test. + response = dh.DeploymentHelper.execute_workflow( + organization_name="org", + api=_api(), + file_objs=[MagicMock()], + timeout=-1, + ) + + # Nothing is dispatched... + mocks["WorkflowHelper"].execute_workflow_async.assert_not_called() + # ...the row is terminalised here instead of being left PENDING, and the + # counters are written so the run does not read back as a clean success... + mocks[ + "WorkflowExecutionServiceHelper" + ].update_execution_completed.assert_called_once_with( + "exec-123", total_files=1, failed_files=1 + ) + # ...the slot and staging dir are released. The slot must be released by org + # id string: release_slot formats its argument into the Redis key, so passing + # the model instance removes a non-member and silently holds the slot. + mocks["APIDeploymentRateLimiter"].release_slot.assert_called_once_with( + "org-uuid-1", "exec-123" + ) + mocks["DestinationConnector"].delete_api_storage_dir.assert_called_once() + # ...and the caller still sees why each file failed. + assert response["execution_status"] == "COMPLETED" + assert response["result"][0]["file"] == "evil.pdf" + assert response["result"][0]["status"] == "Failed" + + +def test_all_files_rejected_acknowledges_and_notifies( + staging_rejects_everything, +) -> None: + """The early return owes the caller what the dispatch path would have done. + + It hands back the results in its own response and reaches a terminal status + without going through WorkflowHelper, so both the acknowledgement and the + subscriber notification have to happen here or they happen nowhere. + """ + mocks = staging_rejects_everything + completed_row = mocks[ + "WorkflowExecutionServiceHelper" + ].update_execution_completed.return_value + + dh.DeploymentHelper.execute_workflow( + organization_name="org", + api=_api(), + file_objs=[MagicMock()], + timeout=-1, + ) + + # Results were served in this response, so a later GET /status must 406 + # rather than serve them again. + mocks["WorkflowHelper"].set_result_acknowledge.assert_called_once_with(completed_row) + # PipelineUtils is the only dispatcher of API deployment notifications. + mocks["PipelineUtils"].update_pipeline_status.assert_called_once_with( + pipeline_id="pipe-1", workflow_execution=completed_row + ) + + +def test_notification_survives_a_failed_acknowledgement( + staging_rejects_everything, +) -> None: + """Acknowledgement and notification are independent obligations. + + Sharing one try block would let a failed acknowledgement silence the + notification that subscribers depend on. + """ + mocks = staging_rejects_everything + mocks["WorkflowHelper"].set_result_acknowledge.side_effect = Exception("db is down") + + response = dh.DeploymentHelper.execute_workflow( + organization_name="org", + api=_api(), + file_objs=[MagicMock()], + timeout=-1, + ) + + mocks["PipelineUtils"].update_pipeline_status.assert_called_once() + assert response["execution_status"] == "COMPLETED" + + +def test_all_files_rejected_still_responds_if_notification_fails( + staging_rejects_everything, +) -> None: + """A failing webhook must not turn a handled rejection into a 500.""" + mocks = staging_rejects_everything + mocks["PipelineUtils"].update_pipeline_status.side_effect = Exception("webhook down") + + response = dh.DeploymentHelper.execute_workflow( + organization_name="org", + api=_api(), + file_objs=[MagicMock()], + timeout=-1, + ) + + assert response["execution_status"] == "COMPLETED" + assert response["result"][0]["file"] == "evil.pdf" + + +def test_files_staged_successfully_are_dispatched(staging_rejects_everything) -> None: + """The short-circuit must not fire when staging did return files. + + Sibling to the test above: together they pin the branch to the staging result + rather than to the upload list. + """ + mocks = staging_rejects_everything + mocks["SourceConnector"].add_input_file_to_api_storage.return_value = { + "good.pdf": MagicMock() + } + + dh.DeploymentHelper.execute_workflow( + organization_name="org", + api=_api(), + file_objs=[MagicMock()], + timeout=-1, + ) + + mocks["WorkflowHelper"].execute_workflow_async.assert_called_once() + mocks["WorkflowExecutionServiceHelper"].update_execution_completed.assert_not_called() + + +def test_all_files_rejected_cleanup_survives_db_marking_error( + staging_rejects_everything, +) -> None: + """A failing status write must not strand the slot or the staging dir. + + update_execution_completed only catches DoesNotExist, so a lock timeout or a + dropped connection propagates; without isolation the org's rate limit slot + stays held for its full TTL and throttles every other call for that org. + """ + mocks = staging_rejects_everything + mocks[ + "WorkflowExecutionServiceHelper" + ].update_execution_completed.side_effect = Exception("db is down") + + response = dh.DeploymentHelper.execute_workflow( + organization_name="org", + api=_api(), + file_objs=[MagicMock()], + timeout=-1, + ) + + mocks["APIDeploymentRateLimiter"].release_slot.assert_called_once() + mocks["DestinationConnector"].delete_api_storage_dir.assert_called_once() + # The row never reached COMPLETED, so the response must not claim it did. + assert response["execution_status"] == "ERROR" diff --git a/backend/workflow_manager/endpoint_v2/enums.py b/backend/workflow_manager/endpoint_v2/enums.py index 79725ea005..7fa01dd1b3 100644 --- a/backend/workflow_manager/endpoint_v2/enums.py +++ b/backend/workflow_manager/endpoint_v2/enums.py @@ -1,5 +1,10 @@ from enum import Enum +from unstract.core.mime_gate import ( # noqa: F401 re-exported + INCONCLUSIVE_MIME_TYPES, + resolve_inconclusive_mime_type, +) + class FileProcessingStatus(Enum): SUCCESS = "SUCCESS" @@ -7,6 +12,12 @@ class FileProcessingStatus(Enum): class AllowedFileTypes(Enum): + """MIME types accepted into a workflow. + + Keep in sync with LLMWhisperer's supported types, and with the copy in the + workers package. + """ + PLAIN_TEXT = "text/plain" PDF = "application/pdf" JPEG = "image/jpeg" @@ -19,7 +30,6 @@ class AllowedFileTypes(Enum): DOC = "application/msword" XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" XLS = "application/vnd.ms-excel" - XLSM = "application/vnd.ms-excel.sheet.macroenabled.12" PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation" PPT = "application/vnd.ms-powerpoint" ODT = "application/vnd.oasis.opendocument.text" @@ -28,8 +38,14 @@ class AllowedFileTypes(Enum): CDFV2 = "application/CDFV2" JSON = "application/json" CSV = "text/csv" - OCTET_STREAM = "application/octet-stream" @classmethod def is_allowed(cls, mime_type: str) -> bool: + """Whether LLMWhisperer would accept this type. + + Any `text/*` passes; html, xml, tsv and the like reach the extractor as + text rather than as listed formats. + """ + if mime_type.startswith("text/"): + return True return mime_type in cls._value2member_map_ diff --git a/backend/workflow_manager/endpoint_v2/source.py b/backend/workflow_manager/endpoint_v2/source.py index 23b0d04f2c..4de2edd045 100644 --- a/backend/workflow_manager/endpoint_v2/source.py +++ b/backend/workflow_manager/endpoint_v2/source.py @@ -2,7 +2,7 @@ import logging import os import shutil -import uuid +import tempfile from collections.abc import Collection from hashlib import sha256 from io import BytesIO @@ -25,8 +25,16 @@ SourceConstant, SourceKey, ) -from workflow_manager.endpoint_v2.dto import FileHash, SourceConfig -from workflow_manager.endpoint_v2.enums import AllowedFileTypes +from workflow_manager.endpoint_v2.dto import ( + FileExecutionResult, + FileHash, + SourceConfig, +) +from workflow_manager.endpoint_v2.enums import ( + INCONCLUSIVE_MIME_TYPES, + AllowedFileTypes, + resolve_inconclusive_mime_type, +) from workflow_manager.endpoint_v2.exceptions import ( InvalidInputDirectory, InvalidSourceConnectionType, @@ -37,6 +45,7 @@ UnsupportedMimeTypeError, ) from workflow_manager.endpoint_v2.models import WorkflowEndpoint +from workflow_manager.endpoint_v2.result_cache_utils import ResultCacheUtils from workflow_manager.file_execution.models import WorkflowFileExecution from workflow_manager.utils.workflow_log import WorkflowLog from workflow_manager.workflow_v2.enums import ExecutionStatus @@ -46,6 +55,7 @@ from workflow_manager.workflow_v2.models.workflow import Workflow from unstract.connectors.filesystems.unstract_file_system import UnstractFileSystem +from unstract.core.mime_gate import identify_zip_container from unstract.filesystem import FileStorageType, FileSystem from unstract.sdk1.file_storage import FileStorage from unstract.workflow_execution.enums import LogLevel, LogStage, LogState @@ -69,6 +79,10 @@ class SourceConnector(BaseConnector): """ READ_CHUNK_SIZE = 4194304 # Chunk size for reading files + # Most formats are identifiable from their leading bytes. + MIME_DETECT_CHUNK_SIZE = 8192 + # Shared, so the API and connector paths agree on what counts as undecided. + CONTAINER_MIME_TYPES = INCONCLUSIVE_MIME_TYPES def __init__( self, @@ -1000,7 +1014,9 @@ def add_input_from_connector_to_volume(self, file_hash: FileHash) -> str: file_content_hash.update(chunk) if first_iteration: # Detect MIME type using first chunk - mime_type = magic.from_buffer(chunk, mime=True) + mime_type = resolve_inconclusive_mime_type( + magic.from_buffer(chunk, mime=True), chunk + ) logger.info( f"Detected MIME type: {mime_type} for file {input_file_path}" ) @@ -1187,6 +1203,143 @@ def load_file(self, input_file_path: str) -> tuple[str, BytesIO]: return os.path.basename(input_file_path), file_stream + @classmethod + def _detect_uploaded_file_mime_type(cls, file: UploadedFile) -> str | None: + """Detect an uploaded file's MIME type from its own bytes. + + The multipart Content-Type is supplied by the caller and never verified, + so it cannot be used to decide what is allowed into API storage. + + Returns None for an empty upload, which has no type to judge. + """ + sample = file.read(cls.MIME_DETECT_CHUNK_SIZE) + file.seek(0) + if not sample: + # An empty upload is its own failure downstream, not a type error. + return None + + mime_type = magic.from_buffer(sample, mime=True) + if mime_type in cls.CONTAINER_MIME_TYPES: + mime_type = cls._detect_container_mime_type(file, fallback=mime_type) + return resolve_inconclusive_mime_type(mime_type, sample) + + @classmethod + def _detect_container_mime_type(cls, file: UploadedFile, fallback: str) -> str: + """Resolve a wrapper by classifying the file from a path. + + A path rather than a buffer, because libmagic will not name a zip + container from one; an in-memory upload is spilled to a temp file. + """ + temporary_file_path = getattr(file, "temporary_file_path", None) + if temporary_file_path is not None: + return cls._classify_file_path(temporary_file_path(), fallback) + + with tempfile.NamedTemporaryFile(suffix=".upload") as spill: + for chunk in file.chunks(chunk_size=cls.READ_CHUNK_SIZE): + spill.write(chunk) + spill.flush() + file.seek(0) + return cls._classify_file_path(spill.name, fallback) + + @classmethod + def _classify_file_path(cls, path: str, fallback: str) -> str: + """Classify a staged path, looking inside a zip when that is all we get.""" + mime_type = magic.from_file(path, mime=True) + if mime_type == "application/zip": + inside = identify_zip_container(path) + return inside or mime_type + return mime_type or fallback + + @classmethod + def _report_rejected_file( + cls, + workflow_log: WorkflowLog, + workflow_id: str, + execution_id: str, + file: UploadedFile, + file_name: str, + mime_type: str | None, + log_message: str, + position: int, + ) -> None: + """Record a rejection for the caller without risking the whole request. + + Runs per file inside the staging loop, so nothing here may escape and + cost the caller the files already staged. + """ + workflow_log.log_error(logger=logger, message=log_message) + try: + ResultCacheUtils.update_api_results( + workflow_id=workflow_id, + execution_id=execution_id, + api_result=FileExecutionResult(file=file_name, error=log_message), + ) + except Exception: + logger.exception( + f"Could not record the rejection of '{file_name}' for the caller" + ) + + try: + cls._persist_rejected_file_execution( + execution_id=execution_id, + file=file, + file_name=file_name, + mime_type=mime_type, + log_message=log_message, + position=position, + ) + except Exception: + logger.exception( + f"Could not persist a file execution row for rejected '{file_name}'" + ) + + @classmethod + def _persist_rejected_file_execution( + cls, + execution_id: str, + file: UploadedFile, + file_name: str, + mime_type: str | None, + log_message: str, + position: int, + ) -> None: + """Record a rejected file as a terminal ERROR file execution. + + The cached result is short-lived, so the row is what keeps the rejection + both auditable and countable. + """ + workflow_execution = WorkflowExecution.objects.get(pk=execution_id) + + # The row's identity rests on this alone, since the API path has no + # file_path to tell two rejections apart. Content, name and the upload's + # position in the request, so every rejected part gets its own record + # even when two are byte-identical and share a name. Not a plain content + # hash on purpose: this file was never processed and must not look to + # file history as though it were. + digest = sha256() + for chunk in file.chunks(chunk_size=cls.READ_CHUNK_SIZE): + digest.update(chunk) + file.seek(0) + row_identity = sha256( + digest.digest() + file_name.encode("utf-8") + str(position).encode("ascii") + ).hexdigest() + + file_execution = WorkflowFileExecution.objects.get_or_create_file_execution( + workflow_execution=workflow_execution, + file_hash=FileHash( + file_path=None, + source_connection_type=WorkflowEndpoint.ConnectionType.API, + file_name=file_name, + file_hash=row_identity, + file_size=file.size, + mime_type=mime_type, + ), + is_api=True, + ) + file_execution.update_status( + status=ExecutionStatus.ERROR, execution_error=log_message + ) + @classmethod def add_input_file_to_api_storage( cls, @@ -1224,34 +1377,53 @@ def add_input_file_to_api_storage( file_hashes: dict[str, FileHash] = {} unique_file_hashes: set[str] = set() connection_type = WorkflowEndpoint.ConnectionType.API - for file in file_objs: + for position, file in enumerate(file_objs): file_name = file.name destination_path = os.path.join(api_storage_dir, file_name) - mime_type = file.content_type - logger.info(f"Detected MIME type: {mime_type} for file {file_name}") - if not mime_type: - logger.info( - f"MIME type not found for file {file_name}, using default MIME type: {AllowedFileTypes.OCTET_STREAM.value}" + try: + mime_type = cls._detect_uploaded_file_mime_type(file) + except (OSError, ValueError, magic.MagicException): + # Narrow on purpose: these are faults in THIS upload's bytes, so + # failing the one file is right. A broken libmagic database or an + # unreadable temp dir hits every file in every request, and + # swallowing that would tell each caller their files are invalid + # while the platform is down - it must propagate and fail loudly. + log_message = ( + f"Rejecting file '{file_name}': could not determine its type" + ) + logger.exception(log_message) + cls._report_rejected_file( + workflow_log, + workflow_id, + execution_id, + file, + file_name, + None, + log_message, + position, ) - mime_type = AllowedFileTypes.OCTET_STREAM.value + continue - if not AllowedFileTypes.is_allowed(mime_type): - log_message = f"Skipping file '{file_name}' to stage due to unsupported MIME type '{mime_type}'" - workflow_log.log_info(logger=logger, message=log_message) - # Generate a clearly marked temporary hash to avoid reading the file content - # Helps to prevent duplicate entries in file executions - fake_hash = f"temp-hash-{uuid.uuid4().hex}" - file_hash = FileHash( - file_path=destination_path, - source_connection_type=connection_type, - file_name=file_name, - file_hash=fake_hash, - is_executed=True, - file_size=file.size, - mime_type=mime_type, + logger.info(f"Detected MIME type: {mime_type} for file {file_name}") + + if mime_type is not None and not AllowedFileTypes.is_allowed(mime_type): + log_message = ( + f"Rejecting file '{file_name}' with unsupported MIME type " + f"'{mime_type}'" + ) + # Rejected files are never dispatched, so nothing downstream will + # report on them - surface the failure in the API response here. + cls._report_rejected_file( + workflow_log, + workflow_id, + execution_id, + file, + file_name, + mime_type, + log_message, + position, ) - file_hashes.update({file_name: file_hash}) continue file_system = FileSystem(FileStorageType.API_EXECUTION) diff --git a/backend/workflow_manager/endpoint_v2/tests/test_api_storage_mime_validation.py b/backend/workflow_manager/endpoint_v2/tests/test_api_storage_mime_validation.py new file mode 100644 index 0000000000..7d466ff867 --- /dev/null +++ b/backend/workflow_manager/endpoint_v2/tests/test_api_storage_mime_validation.py @@ -0,0 +1,970 @@ +"""MIME validation for files staged into API storage. + +``SourceConnector.add_input_file_to_api_storage`` is the single funnel through +which API-deployment uploads reach the API storage bucket, so an unsupported +file has to be rejected here or it reaches the extraction step and fails there +with an error that does not name the real cause. + +Unit tests: the real classmethod runs with its DB/storage-touching +collaborators patched on the imported module, so no database is needed. MIME +detection itself is deliberately *not* patched — sniffing the bytes with +libmagic is the behaviour under test. +""" + +import io +import zipfile +from unittest import mock +from unittest.mock import MagicMock + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploadedFile + +import workflow_manager.endpoint_v2.source as src_mod +from workflow_manager.endpoint_v2.constants import ApiDeploymentResultStatus +from workflow_manager.endpoint_v2.source import SourceConnector + +# Bytes chosen from what libmagic actually reports (verified against the pinned +# python-magic). A WAV header is the unsupported case: audio/x-wav is neither +# text/* nor a listed type, and RIFF classifies stably on every libmagic build. +# HTML is the opposite case - it looks like something to reject, but it reaches +# the extractor as text/*, so the gate has to let it through. +PDF_BYTES = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n" +WAV_BYTES = b"RIFF\x24\x08\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x02\x00" +HTML_BYTES = b"hello" + + +# A package declares its main part and relates it from the root; an archive +# carrying only the member names is what the extractor cannot open. +_OOXML_MAIN = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ".main+xml" +) +_OOXML_CONTENT_TYPES = ( + '' + f'' + "" +) +_OOXML_RELS = ( + '' + '' + "" +) + +API_STORAGE_DIR = "/api-storage/exec-1" + + +@pytest.fixture +def collaborators(): + """Patch everything the staging loop touches except MIME detection.""" + with ( + mock.patch.multiple( + src_mod, + UserContext=mock.DEFAULT, + WorkflowLog=mock.DEFAULT, + Workflow=mock.DEFAULT, + FileSystem=mock.DEFAULT, + FileHistoryHelper=mock.DEFAULT, + ResultCacheUtils=mock.DEFAULT, + WorkflowExecution=mock.DEFAULT, + WorkflowFileExecution=mock.DEFAULT, + ) as mocks, + mock.patch.object( + SourceConnector, + "get_api_storage_dir_path", + return_value=API_STORAGE_DIR, + ), + ): + storage = MagicMock() + mocks["FileSystem"].return_value.get_file_storage.return_value = storage + mocks["storage"] = storage + yield mocks + + +def _upload(name: str, content: bytes, declared: str) -> SimpleUploadedFile: + """An uploaded file whose declared Content-Type may not match its bytes.""" + return SimpleUploadedFile(name, content, content_type=declared) + + +def _stage(files): + return SourceConnector.add_input_file_to_api_storage( + pipeline_id="pipe-1", + workflow_id="wf-1", + execution_id="exec-1", + file_objs=files, + ) + + +def _staged_names(storage: MagicMock) -> set[str]: + """File names that actually had bytes written to API storage.""" + return { + call.kwargs["path"].rsplit("/", 1)[-1] for call in storage.write.call_args_list + } + + +def test_supported_file_is_staged(collaborators) -> None: + """A real PDF is staged and returned for dispatch.""" + result = _stage([_upload("doc.pdf", PDF_BYTES, "application/pdf")]) + + assert set(result) == {"doc.pdf"} + assert result["doc.pdf"].mime_type == "application/pdf" + assert _staged_names(collaborators["storage"]) == {"doc.pdf"} + + +def test_unsupported_bytes_rejected_despite_supported_declared_type( + collaborators, +) -> None: + """The declared Content-Type must not decide what reaches the bucket. + + A WAV file announced as application/pdf satisfies any header-based check, + so only sniffing the bytes keeps it out. + """ + result = _stage([_upload("evil.pdf", WAV_BYTES, "application/pdf")]) + + # Never dispatched... + assert result == {} + # ...and never written to the bucket. + collaborators["storage"].write.assert_not_called() + + +def test_rejection_is_reported_to_the_caller(collaborators) -> None: + """A rejected file gets its own failed entry in the API response.""" + _stage([_upload("evil.pdf", WAV_BYTES, "application/pdf")]) + + collaborators["ResultCacheUtils"].update_api_results.assert_called_once() + api_result = collaborators["ResultCacheUtils"].update_api_results.call_args.kwargs[ + "api_result" + ] + assert api_result.file == "evil.pdf" + # The message has to name the offending type, not a downstream symptom. + assert "audio/x-wav" in api_result.error + assert api_result.status == ApiDeploymentResultStatus.FAILED + + +def test_rejection_is_persisted_as_a_failed_file_execution(collaborators) -> None: + """A rejection has to outlive the result cache. + + The cache entry is deleted on the first status poll and expires with the + result TTL, so without a row nothing can answer "why was my file not + processed?" afterwards. The row is also what makes the rejection countable: + the execution serializer derives successful/failed by counting these, so a + partially-rejected run stops reading as a clean success. + """ + _stage( + [ + _upload("good.pdf", PDF_BYTES, "application/pdf"), + _upload("evil.pdf", WAV_BYTES, "application/pdf"), + ] + ) + + manager = collaborators["WorkflowFileExecution"].objects + manager.get_or_create_file_execution.assert_called_once() + kwargs = manager.get_or_create_file_execution.call_args.kwargs + assert kwargs["is_api"] is True + recorded = kwargs["file_hash"] + assert recorded.file_name == "evil.pdf" + # The real type is what a support question needs, not the declared one. + assert recorded.mime_type == "audio/x-wav" + # An opaque identity, not the raw name: the API path has no file_path, so + # this value is the only thing keeping two rejections apart. + assert recorded.file_hash + assert recorded.file_hash != "evil.pdf" + + created = manager.get_or_create_file_execution.return_value + created.update_status.assert_called_once() + status_kwargs = created.update_status.call_args.kwargs + assert status_kwargs["status"] is src_mod.ExecutionStatus.ERROR + assert "audio/x-wav" in status_kwargs["execution_error"] + + +def test_two_rejected_files_get_two_rows(collaborators) -> None: + """Two rejections must never fold into one row. + + Identical bytes under different names is the case that breaks a + content-only identity: the second upload would reuse the first row, whose + name was set at creation, so one rejection would be recorded instead of two + and the counts would be short by one. + """ + _stage( + [ + _upload("a.pdf", WAV_BYTES, "application/pdf"), + _upload("b.pdf", WAV_BYTES, "application/pdf"), + ] + ) + + manager = collaborators["WorkflowFileExecution"].objects + hashes = { + call.kwargs["file_hash"].file_hash + for call in manager.get_or_create_file_execution.call_args_list + } + assert len(hashes) == 2 + + +def test_two_identical_same_named_rejections_get_two_rows(collaborators) -> None: + """Every rejected part is its own record. + + Same bytes under the same name is the case content and name alone cannot + separate, so the counts would be short by one for each repeat. + """ + _stage( + [ + _upload("same.pdf", WAV_BYTES, "application/pdf"), + _upload("same.pdf", WAV_BYTES, "application/pdf"), + ] + ) + + manager = collaborators["WorkflowFileExecution"].objects + identities = { + call.kwargs["file_hash"].file_hash + for call in manager.get_or_create_file_execution.call_args_list + } + assert len(identities) == 2 + + +def test_a_failed_row_write_does_not_fail_the_request(collaborators) -> None: + """Bookkeeping must not cost the caller their good files.""" + collaborators[ + "WorkflowFileExecution" + ].objects.get_or_create_file_execution.side_effect = Exception("db is down") + + result = _stage( + [ + _upload("good.pdf", PDF_BYTES, "application/pdf"), + _upload("evil.pdf", WAV_BYTES, "application/pdf"), + ] + ) + + assert set(result) == {"good.pdf"} + + +def test_missing_declared_type_falls_back_to_sniffed_type(collaborators) -> None: + """A supported file with no declared Content-Type is still staged. + + The recorded type comes from the bytes, so an absent header neither blocks + the file nor degrades it to application/octet-stream. + """ + result = _stage([_upload("doc.pdf", PDF_BYTES, "")]) + + assert result["doc.pdf"].mime_type == "application/pdf" + + +def test_supported_files_survive_a_rejected_sibling(collaborators) -> None: + """One bad file does not fail the whole request.""" + result = _stage( + [ + _upload("good.pdf", PDF_BYTES, "application/pdf"), + _upload("evil.pdf", WAV_BYTES, "application/pdf"), + ] + ) + + assert set(result) == {"good.pdf"} + assert _staged_names(collaborators["storage"]) == {"good.pdf"} + + +def _ole2_like(total_size: int) -> bytes: + """An OLE2 compound file whose format markers sit past the sample window. + + libmagic resolves .doc/.xls/.ppt through the OLE2 directory sector, which + lives at the end of the file. Only the container signature is visible in the + leading bytes, which is exactly the shape that made these files unstageable. + """ + header = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 504 + return header + b"\x00" * (total_size - len(header)) + + +def _docx_bytes(pad_first_member: bool) -> bytes: + """A minimal but genuine OOXML package. + + With `pad_first_member`, `[Content_Types].xml` is no longer the first entry + — what re-zipping or a streaming writer produces. libmagic then declines to + name it beyond `application/zip`, which is the shape that has to be resolved + by looking inside rather than by asking libmagic again. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + if pad_first_member: + archive.writestr("junk.bin", bytes(range(256)) * 4) + archive.writestr("[Content_Types].xml", _OOXML_CONTENT_TYPES) + archive.writestr("_rels/.rels", _OOXML_RELS) + archive.writestr("word/document.xml", '') + return buf.getvalue() + + +@pytest.mark.parametrize("repackaged", [False, True], ids=["normal", "repackaged"]) +def test_real_ooxml_bytes_are_accepted(collaborators, repackaged: bool) -> None: + """Real container bytes, with libmagic unmocked. + + Every other container test scripts libmagic's answers, so none of them would + notice the detector asking a question libmagic cannot answer. A repackaged + OOXML package reads as a bare zip however many bytes it is given, so it is + the case that proves the resolution works rather than the mocks agreeing + with each other. + """ + data = _docx_bytes(pad_first_member=repackaged) + + result = _stage([_upload("report.docx", data, "application/pdf")]) + + assert set(result) == {"report.docx"} + assert result["report.docx"].mime_type == ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + # Detection copies the upload to classify it; staging must still see it all. + written = b"".join( + call.kwargs["data"] for call in collaborators["storage"].write.call_args_list + ) + assert written == data + + +def test_an_oversized_mimetype_member_is_not_decompressed(collaborators) -> None: + """A hostile archive must not turn the type check into a decompression bomb. + + `mimetype` holds one media type string, so a member declaring megabytes is + not the thing being looked for. Reading it unbounded would materialise + whatever the archive declares, during synchronous staging, failing the whole + request before anything is dispatched. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + # Highly compressible, so the archive stays small while the member does not. + archive.writestr("mimetype", "a" * (8 * 1024 * 1024)) + payload = buf.getvalue() + assert len(payload) < 100_000 # small on the wire, large on the way out + + result = _stage([_upload("bomb.pdf", payload, "application/pdf")]) + + # Not recognised, and never expanded to find that out. + assert result == {} + + +def _odf_bytes(declared: str, *, stored: bool = True, first: bool = True) -> bytes: + """An ODF-shaped archive declaring `declared` in its mimetype member.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + if not first: + archive.writestr("junk.bin", "x" * 64) + archive.writestr( + zipfile.ZipInfo("mimetype"), + declared, + compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED, + ) + archive.writestr("content.xml", "") + return buf.getvalue() + + +def test_a_genuine_odf_document_is_recognised(collaborators) -> None: + """The mimetype member identifies which ODF document this is.""" + data = _odf_bytes("application/vnd.oasis.opendocument.text") + + result = _stage([_upload("notes.pdf", data, "application/pdf")]) + + assert set(result) == {"notes.pdf"} + assert result["notes.pdf"].mime_type == "application/vnd.oasis.opendocument.text" + + +@pytest.mark.parametrize( + "declared, stored, first, why", + [ + ("application/pdf", True, True, "names an unrelated format"), + ("text/plain", True, True, "names a format it cannot vouch for"), + ("application/vnd.oasis.opendocument.text", False, True, "is compressed"), + ("application/vnd.oasis.opendocument.text", True, False, "is not first"), + ], +) +def test_a_mimetype_member_cannot_nominate_a_format( + collaborators, declared: str, stored: bool, first: bool, why: str +) -> None: + """The member is archive-controlled, so it may identify but never choose. + + Without this an arbitrary zip declaring an allow-listed type would be waved + straight through — recreating the deferred extraction failure the whole gate + exists to prevent. The spec's own conditions are what make it evidence. + """ + data = _odf_bytes(declared, stored=stored, first=first) + + result = _stage([_upload("spoof.pdf", data, "application/pdf")]) + + assert result == {}, f"accepted an archive whose mimetype member {why}" + + +@pytest.mark.parametrize( + "stored", [True, False], ids=["stored-entry", "deflated-entry"] +) +def test_a_zip_carrying_a_pdf_marker_is_still_a_zip(collaborators, stored: bool) -> None: + """A zip whose contents mention a PDF header must not be staged as a PDF. + + An entry stored uncompressed puts `%PDF-1.7` in the archive's literal leading + bytes, so a scan of the head sees it exactly as it would in a real document. + Resolving the wrapper before the marker is consulted is what keeps that from + becoming a way past the gate. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as archive: + archive.writestr( + zipfile.ZipInfo("a.txt"), + "%PDF-1.7 pretending to be a pdf", + compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED, + ) + archive.writestr("b.bin", bytes(range(256)) * 4) + data = buf.getvalue() + if stored: + assert b"%PDF-1.7" in data[:1024], "fixture must put the marker in the head" + + result = _stage([_upload("archive.pdf", data, "application/pdf")]) + + assert result == {} + collaborators["storage"].write.assert_not_called() + + +def test_an_incomplete_ooxml_package_is_rejected(collaborators) -> None: + """Marker directories alone are not a document. + + Scoped to the repackaged shape, which is the one resolved by reading the + archive: with `[Content_Types].xml` first, libmagic names the package itself + and this code never runs. An archive with the markers but no relationships + is not loadable, so naming it a document would only move the failure to + extraction — verified live, where one of exactly this shape was accepted + here and then rejected by the extractor. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + # Ahead of the markers, so libmagic will only call this a zip. + archive.writestr("junk.bin", bytes(range(256)) * 4) + # Every member name a package has, but nothing declaring a main part. + archive.writestr("[Content_Types].xml", '') + archive.writestr("_rels/.rels", '') + archive.writestr("word/document.xml", '') + data = buf.getvalue() + # Which wrapper libmagic names here differs between builds - octet-stream on + # some, application/zip on others - so assert only the property the test + # depends on: it is not named a document, so the archive gets inspected. + assert src_mod.magic.from_buffer(data, mime=True) in src_mod.INCONCLUSIVE_MIME_TYPES + + result = _stage([_upload("partial.pdf", data, "application/pdf")]) + + assert result == {} + + +@pytest.mark.parametrize( + "wrapper", ["application/zip", "application/x-ole-storage"] +) +def test_a_recognised_wrapper_is_not_promoted_by_its_contents( + collaborators, wrapper: str +) -> None: + """A wrapper libmagic named keeps its own answer. + + Both wrappers, because excluding only one leaves the other able to be + reclassified as a PDF by a marker sitting in its payload. + """ + payload = b"\x00" * 64 + b"%PDF-1.7 not a pdf" + b"\x00" * 512 + + # First call classifies the sample; a second would only happen if the rescue + # ran, and it answers application/pdf so a promotion would be visible as an + # accepted file rather than hidden behind the same wrapper verdict. + with mock.patch.object( + src_mod.magic, "from_buffer", side_effect=[wrapper, "application/pdf"] + ), mock.patch.object(src_mod.magic, "from_file", return_value=wrapper): + result = _stage([_upload("wrapped.pdf", payload, "application/pdf")]) + + assert result == {} + + +def _ooxml_archive(content_types: str, rels: str, part: str) -> bytes: + """A repackaged archive with the three members set as given.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + # Ahead of the markers, so libmagic will only call this a zip and the + # archive has to be read to resolve it. + archive.writestr("junk.bin", bytes(range(256)) * 4) + archive.writestr("[Content_Types].xml", content_types) + archive.writestr("_rels/.rels", rels) + archive.writestr(part, "") + return buf.getvalue() + + +@pytest.mark.parametrize( + "content_types, rels, part, why", + [ + ( + '', + '', + "word/document.xml", + "carries the marker as stray text and relates nothing", + ), + ( + _OOXML_CONTENT_TYPES, + '', + "word/document.xml", + "declares a main part that nothing relates", + ), + ( + _OOXML_CONTENT_TYPES, + _OOXML_RELS.replace("word/document.xml", "word/other.xml"), + "word/document.xml", + "relates a different part than it declares", + ), + ( + _OOXML_CONTENT_TYPES, + _OOXML_RELS, + "word/styles.xml", + "declares and relates a part the archive does not contain", + ), + ], + ids=["stray-marker", "unrelated", "disagrees", "part-absent"], +) +def test_an_archive_wearing_the_names_is_not_a_document( + collaborators, content_types: str, rels: str, part: str, why: str +) -> None: + """Names and markers are not evidence; the declaration must hold together. + + The package has to declare its main part, relate that same part from the + root, and actually contain it. Each case here satisfies some of that and is + still not openable, so accepting it would only move the failure to + extraction — which the live run of one of these shapes confirmed. + """ + data = _ooxml_archive(content_types, rels, part) + + result = _stage([_upload("spoof.pdf", data, "application/pdf")]) + + assert result == {}, f"accepted an archive that {why}" + + +def test_attribute_order_and_quoting_do_not_matter(collaborators) -> None: + """A real package is still recognised however its XML is written.""" + main = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml" + ".document.main+xml" + ) + rel = ( + "http://schemas.openxmlformats.org/officeDocument/2006/" + "relationships/officeDocument" + ) + data = _ooxml_archive( + f"", + f"", + "word/document.xml", + ) + + result = _stage([_upload("report.docx", data, "application/pdf")]) + + assert result["report.docx"].mime_type == ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + + +_ODT = "application/vnd.oasis.opendocument.text" + + +def _odf_archive(declared: str, manifest: str | None) -> bytes: + """An ODF-shaped archive: mimetype first and stored, as the spec requires.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + zipfile.ZipInfo("mimetype"), declared, compress_type=zipfile.ZIP_STORED + ) + if manifest is not None: + archive.writestr("META-INF/manifest.xml", manifest) + archive.writestr("content.xml", "") + return buf.getvalue() + + +def _odf_manifest(media_type: str) -> str: + return ( + '' + f'' + ) + + +@pytest.mark.parametrize( + "manifest, expected, why", + [ + (_odf_manifest(_ODT), _ODT, "manifest agrees with the mimetype member"), + (None, None, "there is no manifest"), + ( + "", + None, + "the manifest declares nothing", + ), + (_odf_manifest("application/zip"), None, "the manifest disagrees"), + ], + ids=["agrees", "no-manifest", "empty-manifest", "disagrees"], +) +def test_odf_is_recognised_only_when_its_manifest_agrees( + tmp_path, manifest: str | None, expected: str | None, why: str +) -> None: + """The mimetype member is archive-controlled, so it needs corroborating. + + Exercised against the helper directly: libmagic names this archive shape + itself, so staging never escalates it and the branch would otherwise go + untested — and libmagic's container verdicts differ between builds, so the + branch is reachable even though it is not on this one. + """ + archive = tmp_path / "candidate.zip" + archive.write_bytes(_odf_archive(_ODT, manifest)) + + assert src_mod.identify_zip_container(str(archive)) == expected, why + + +@pytest.mark.parametrize( + "content_types, why", + [ + ( + '", + "declares its main part only inside a comment", + ), + ( + ']>&a;', + "carries a DTD, which is never parsed", + ), + ], + ids=["commented-out", "dtd"], +) +def test_a_declaration_must_survive_parsing( + collaborators, content_types: str, why: str +) -> None: + """Matching raw bytes accepted things parsing does not. + + A comment satisfied a pattern looking for the Override tag, and a DTD is an + expansion risk rather than a declaration, so both are read by parsing the + member instead. + """ + data = _ooxml_archive(content_types, _OOXML_RELS, "word/document.xml") + + result = _stage([_upload("spoof.pdf", data, "application/pdf")]) + + assert result == {}, f"accepted an archive that {why}" + + +def test_a_namespaced_declaration_is_still_recognised(collaborators) -> None: + """Prefixes are a presentation detail, not a different document.""" + main = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml" + ".document.main+xml" + ) + data = _ooxml_archive( + '', + _OOXML_RELS, + "word/document.xml", + ) + + result = _stage([_upload("report.docx", data, "application/pdf")]) + + assert result["report.docx"].mime_type == ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + + +@pytest.mark.parametrize( + "encoding", ["utf-8", "utf-16", "utf-32"], ids=["utf-8", "utf-16", "utf-32"] +) +def test_a_dtd_is_refused_whatever_it_is_encoded_in( + collaborators, encoding: str +) -> None: + """The parser detects the encoding, so the guard has to as well. + + A DTD written in UTF-16 is invisible to a search for ASCII markers but is + still expanded once parsing starts, which is a compact upload turning into + real work during staging. + """ + main = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml" + ".document.main+xml" + ) + declaration = ( + f'' + '' + ']>' + f'' + "&b;" + ) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("junk.bin", bytes(range(256)) * 4) + archive.writestr("[Content_Types].xml", declaration.encode(encoding)) + archive.writestr("_rels/.rels", _OOXML_RELS) + archive.writestr("word/document.xml", "") + + result = _stage([_upload("bomb.pdf", buf.getvalue(), "application/pdf")]) + + assert result == {} + + +def test_a_plain_zip_stays_rejected(collaborators) -> None: + """Looking inside a zip widens recognition, not the allow-list.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("notes.txt", "just a zip of files") + archive.writestr("data.bin", bytes(range(256)) * 8) + + result = _stage([_upload("archive.pdf", buf.getvalue(), "application/pdf")]) + + assert result == {} + collaborators["storage"].write.assert_not_called() + + +def test_container_prefix_triggers_a_full_file_sniff(collaborators) -> None: + """A container type seen in the sample must not decide the verdict alone. + + Pins the regression directly: an OLE2 upload sniffs application/x-ole-storage + from its first bytes, which is absent from AllowedFileTypes, so resolving from + the sample alone rejects every legacy Office file bigger than the window. + + The sniff results are stubbed because libmagic's container reporting differs + between builds; what must hold everywhere is that an inconclusive sample is + escalated to the full file instead of being treated as a verdict. + """ + ole_bytes = _ole2_like(SourceConnector.MIME_DETECT_CHUNK_SIZE * 4) + + with mock.patch.object( + src_mod.magic, "from_buffer", return_value="application/x-ole-storage" + ) as sniff, mock.patch.object( + src_mod.magic, "from_file", return_value="application/msword" + ) as sniff_path: + result = _stage([_upload("legacy.doc", ole_bytes, "application/msword")]) + + # Only the cheap sample came from a buffer... + assert sniff.call_count == 1 + assert len(sniff.call_args_list[0].args[0]) == SourceConnector.MIME_DETECT_CHUNK_SIZE + # ...and the verdict came from a path, which is the only way libmagic will + # name a container. + assert sniff_path.call_count == 1 + # ...and the answer from the full file is what decides. + assert set(result) == {"legacy.doc"} + assert result["legacy.doc"].mime_type == "application/msword" + + +def test_container_still_rejected_when_the_full_file_is_unsupported( + collaborators, +) -> None: + """The full-file re-sniff widens the evidence, not the allow-list.""" + ole_bytes = _ole2_like(SourceConnector.MIME_DETECT_CHUNK_SIZE * 4) + with mock.patch.object( + src_mod.magic, "from_buffer", return_value="application/x-ole-storage" + ), mock.patch.object( + src_mod.magic, "from_file", return_value="application/x-dosexec" + ): + result = _stage([_upload("legacy.doc", ole_bytes, "application/msword")]) + + assert result == {} + collaborators["storage"].write.assert_not_called() + + +def test_container_upload_is_not_consumed_by_detection(collaborators) -> None: + """Reading the whole file to classify it must still leave it stageable.""" + ole_bytes = _ole2_like(SourceConnector.MIME_DETECT_CHUNK_SIZE * 4) + with mock.patch.object( + src_mod.magic, "from_buffer", return_value="application/x-ole-storage" + ), mock.patch.object( + src_mod.magic, "from_file", return_value="application/msword" + ): + _stage([_upload("legacy.doc", ole_bytes, "application/msword")]) + + written = b"".join( + call.kwargs["data"] for call in collaborators["storage"].write.call_args_list + ) + assert written == ole_bytes + + +def test_undetectable_file_fails_alone(collaborators) -> None: + """A stream that cannot be read fails its own file, not the whole request.""" + with mock.patch.object( + SourceConnector, + "_detect_uploaded_file_mime_type", + side_effect=[OSError("stream is gone"), "application/pdf"], + ): + result = _stage( + [ + _upload("broken.pdf", PDF_BYTES, "application/pdf"), + _upload("good.pdf", PDF_BYTES, "application/pdf"), + ] + ) + + assert set(result) == {"good.pdf"} + api_result = collaborators["ResultCacheUtils"].update_api_results.call_args.kwargs[ + "api_result" + ] + assert api_result.file == "broken.pdf" + # An I/O fault and an unsupported format need different follow-ups, so the + # message must not blame the file's type. + assert "could not determine its type" in api_result.error + + +def test_disk_backed_upload_is_classified_from_its_temp_file(collaborators) -> None: + """The branch real legacy-Office uploads actually take must be exercised. + + Django spills anything over FILE_UPLOAD_MAX_MEMORY_SIZE to a + TemporaryUploadedFile, and .doc/.xls/.ppt are usually over it. Every other + test here builds a SimpleUploadedFile, which has no temporary_file_path, so + the in-memory branch is the only one they reach. + """ + ole_bytes = _ole2_like(SourceConnector.MIME_DETECT_CHUNK_SIZE * 4) + upload = TemporaryUploadedFile( + "legacy.doc", "application/msword", len(ole_bytes), None + ) + upload.write(ole_bytes) + upload.seek(0) + assert upload.temporary_file_path() # the branch under test + + with mock.patch.object( + src_mod.magic, "from_buffer", return_value="application/x-ole-storage" + ), mock.patch.object( + src_mod.magic, "from_file", return_value="application/msword" + ) as from_file: + result = _stage([upload]) + + # Classified from the path, so a large upload is never buffered whole... + from_file.assert_called_once_with(upload.temporary_file_path(), mime=True) + # ...and it is staged with the type the full file resolved to. + assert set(result) == {"legacy.doc"} + assert result["legacy.doc"].mime_type == "application/msword" + + +def test_one_bad_file_survives_a_failing_result_cache(collaborators) -> None: + """A cache fault while reporting a rejection must not fail the request. + + update_api_results runs inside the staging loop and its pipeline execute is + unguarded, so letting it escape would mark the whole execution ERROR and + discard the good files already written. + """ + collaborators["ResultCacheUtils"].update_api_results.side_effect = Exception( + "redis is down" + ) + + result = _stage( + [ + _upload("good.pdf", PDF_BYTES, "application/pdf"), + _upload("evil.pdf", WAV_BYTES, "application/pdf"), + ] + ) + + assert set(result) == {"good.pdf"} + assert _staged_names(collaborators["storage"]) == {"good.pdf"} + + +def test_systemic_detection_failure_is_not_reported_as_bad_files( + collaborators, +) -> None: + """A fault that is not about this file's bytes must fail the request loudly. + + A broken libmagic database or an unreadable temp dir hits every file in + every request. Swallowing it per-file would answer 200 COMPLETED with every + file marked invalid, hiding a platform outage behind a clean success. + """ + # Built outside the raises block so only _stage can satisfy it: otherwise + # the test would still pass if the upload itself were what raised. + uploads = [_upload("doc.pdf", PDF_BYTES, "application/pdf")] + + with mock.patch.object( + src_mod.magic, "from_buffer", side_effect=RuntimeError("magic db is broken") + ): + with pytest.raises(RuntimeError, match="magic db is broken"): + _stage(uploads) + + +def test_empty_upload_is_staged_rather_than_called_unsupported(collaborators) -> None: + """An empty file must reach the downstream empty-file error, not a type error. + + libmagic calls zero bytes application/x-empty, which is absent from + AllowedFileTypes; without the short-circuit an empty upload would be reported + as an unsupported type, which names the wrong cause. + """ + result = _stage([_upload("empty.pdf", b"", "application/pdf")]) + + assert set(result) == {"empty.pdf"} + # No bytes means no type to judge, so the gate is skipped rather than passed. + assert result["empty.pdf"].mime_type is None + collaborators["ResultCacheUtils"].update_api_results.assert_not_called() + + +def test_types_llmwhisperer_supports_are_accepted(collaborators) -> None: + """The allow-list mirrors what LLMWhisperer can extract. + + HTML and XML look like things to reject and were rejected until the two sets + were reconciled, but LLMWhisperer extracts both - so rejecting them loses a + file that would have worked. Accepting what it cannot read and rejecting what + it can are the same bug in opposite directions. + """ + result = _stage( + [ + _upload("page.html", HTML_BYTES, "text/html"), + _upload("data.xml", b'1', "text/xml"), + ] + ) + + assert set(result) == {"page.html", "data.xml"} + assert result["page.html"].mime_type == "text/html" + collaborators["ResultCacheUtils"].update_api_results.assert_not_called() + + +def test_pdf_behind_leading_bytes_is_accepted(collaborators) -> None: + """A PDF that does not start at offset 0 must still be recognised. + + libmagic's PDF rule only matches at offset 0, so eight bytes of padding are + enough to make a real PDF sniff as application/octet-stream. Extractors + tolerate the prefix, and LLMWhisperer scans the same window for exactly this + reason, so rejecting it here would fail a file that works everywhere else - + including in ETL runs, which share this allow-list. + """ + wrapped = b"\x00" * 8 + PDF_BYTES + + result = _stage([_upload("wrapped.pdf", wrapped, "application/pdf")]) + + assert set(result) == {"wrapped.pdf"} + assert result["wrapped.pdf"].mime_type == "application/pdf" + collaborators["ResultCacheUtils"].update_api_results.assert_not_called() + + +def test_pdf_marker_inside_other_content_does_not_smuggle_a_file_through( + collaborators, +) -> None: + """The `%PDF-` rescue must not become a way past the gate. + + Promoting on a bare substring would let any unidentifiable blob carrying + that text near its start be staged as a PDF, which is the opposite of what + this check exists for. Re-classifying from the marker's own offset is what + keeps the rescue narrow. + """ + # These bytes must actually sniff as application/octet-stream, or the test + # would pass without ever reaching the rescue it is meant to constrain. + blob = b"\x00\x01\x02\x03" * 30 + b"%PDF- not really a pdf" + b"\x00\x01\x02\x03" * 100 + assert src_mod.magic.from_buffer(blob, mime=True) == "application/octet-stream" + + result = _stage([_upload("smuggled.pdf", blob, "application/pdf")]) + + assert result == {} + collaborators["storage"].write.assert_not_called() + + +def test_a_recognised_zip_is_never_promoted_to_pdf(collaborators) -> None: + """A zip is a zip, whatever its entries happen to contain.""" + with mock.patch.object( + src_mod.magic, "from_buffer", return_value="application/zip" + ), mock.patch.object( + src_mod.magic, "from_file", return_value="application/zip" + ): + result = _stage( + [_upload("archive.pdf", b"PK\x03\x04" + b"%PDF-1.7" * 8, "application/pdf")] + ) + + assert result == {} + + +def test_unidentifiable_binary_is_rejected(collaborators) -> None: + """octet-stream is no longer a free pass. + + Anything libmagic cannot name is something LLMWhisperer cannot extract; it + used to be allow-listed, which is how a zip renamed .pdf reached the + extractor and came back as a 415. + """ + with mock.patch.object( + src_mod.magic, "from_buffer", return_value="application/octet-stream" + ): + result = _stage([_upload("mystery.pdf", b"\x00\x01\x02\x03" * 64, "application/pdf")]) + + assert result == {} + collaborators["storage"].write.assert_not_called() diff --git a/backend/workflow_manager/execution/tests/test_pg_finalization_fixes.py b/backend/workflow_manager/execution/tests/test_pg_finalization_fixes.py index d9cc5ba4da..25770e8c6e 100644 --- a/backend/workflow_manager/execution/tests/test_pg_finalization_fixes.py +++ b/backend/workflow_manager/execution/tests/test_pg_finalization_fixes.py @@ -633,7 +633,7 @@ def test_result_acknowledge_does_not_touch_status_or_counters(self): stale = self._stale( ex, ExecutionStatus.EXECUTING, successful_files=None, failed_files=None ) - WorkflowHelper._set_result_acknowledge(stale) + WorkflowHelper.set_result_acknowledge(stale) ex.refresh_from_db() assert ex.status == ExecutionStatus.COMPLETED.value assert ex.successful_files == 1 diff --git a/backend/workflow_manager/internal_views.py b/backend/workflow_manager/internal_views.py index 66b22d3216..6d6c969022 100644 --- a/backend/workflow_manager/internal_views.py +++ b/backend/workflow_manager/internal_views.py @@ -934,14 +934,53 @@ def _restamp_execution_time_from_files(execution) -> None: @staticmethod def _update_file_aggregates(execution, validated_data) -> None: - """Persist total / successful / failed file counts when present.""" + """Persist total / failed / successful file counts when present. + + Floored at what the file rows say, because the worker's aggregate only + counts files it was dispatched and misses any rejected before that. + """ update_fields: list[str] = [] for field in ("total_files", "successful_files", "failed_files"): if validated_data.get(field) is not None: setattr(execution, field, validated_data[field]) update_fields.append(field) - if update_fields: - execution.save(update_fields=update_fields) + if not update_fields: + return + + WorkflowExecutionInternalViewSet._floor_counters_at_file_rows( + execution, update_fields + ) + execution.save(update_fields=update_fields) + + @staticmethod + def _floor_counters_at_file_rows(execution, update_fields: "list[str]") -> None: + """Raise the counters being written to match the file rows on disk. + + Only ever corrects upward, and only to a count already true, so a run + with nothing rejected is left alone. + """ + wants_failed = "failed_files" in update_fields + wants_total = "total_files" in update_fields + if not (wants_failed or wants_total): + return + + from workflow_manager.workflow_v2.enums import ExecutionStatus + + try: + rows = execution.file_executions + error_rows = rows.filter(status=ExecutionStatus.ERROR.value).count() + row_total = rows.count() + except Exception: + logger.exception( + "Could not read file rows to floor the counters for execution %s", + execution.id, + ) + return + + if wants_failed: + execution.failed_files = max(execution.failed_files or 0, error_rows) + if wants_total: + execution.total_files = max(execution.total_files or 0, row_total) class FileBatchCreateAPIView(APIView): diff --git a/backend/workflow_manager/workflow_v2/execution.py b/backend/workflow_manager/workflow_v2/execution.py index 213c81302b..050639fa06 100644 --- a/backend/workflow_manager/workflow_v2/execution.py +++ b/backend/workflow_manager/workflow_v2/execution.py @@ -387,6 +387,46 @@ def update_execution_err(execution_id: str, err_msg: str = "") -> WorkflowExecut except WorkflowExecution.DoesNotExist: logger.error(f"execution doesn't exist {execution_id}") + @staticmethod + def update_execution_completed( + execution_id: str, total_files: int = 0, failed_files: int = 0 + ) -> WorkflowExecution | None: + """Terminalise an execution that finished without any work to dispatch. + + The counters must be written alongside the status: a terminal row whose + failed_files is NULL reads as a clean success to is_failure_run() and to + run history, which would hide a run whose files were all rejected. + + Returns the row as persisted, so callers can see whether the status + actually changed rather than assuming it did. + """ + try: + execution = WorkflowExecution.objects.get(pk=execution_id) + # Routed through the model method so the PG terminal-one-way guard + # applies; the legacy transport has no such guard. + execution.update_execution(status=ExecutionStatus.COMPLETED) + execution.total_files = total_files + execution.successful_files = 0 + execution.failed_files = failed_files + # Field-scoped for the columns, but save() still republishes this + # object's status to the execution cache. Safe only because the row is + # undispatched here, so no worker can have moved it on. + execution.save( + update_fields=[ + "total_files", + "successful_files", + "failed_files", + "modified_at", + ] + ) + # The guard may have refused the status change without raising; re-read + # so the returned row reflects what is actually stored. + execution.refresh_from_db() + return execution + except WorkflowExecution.DoesNotExist: + logger.error(f"execution doesn't exist {execution_id}") + return None + @staticmethod def update_execution_task(execution_id: str, task_id: str) -> None: try: @@ -459,7 +499,7 @@ def update_execution_queue_message_id( # update_fields) re-runs _handle_execution_cache(), which would republish # this method's stale in-memory status to the Redis execution cache and can # clobber a status the worker has since advanced — the same reason - # _set_result_acknowledge uses a queryset .update(). This marker write must + # set_result_acknowledge uses a queryset .update(). This marker write must # touch ONLY the handle column and never the status/counters or the cache. updated = WorkflowExecution.objects.filter(pk=execution_id).update( queue_message_id=queue_message_id diff --git a/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py b/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py index dcba6ac655..7611837f5b 100644 --- a/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py +++ b/backend/workflow_manager/workflow_v2/tests/test_record_dispatch_handle.py @@ -84,7 +84,7 @@ class TestUpdateQueueMessageIdWriteShape: has advanced status, it reverts the cache to the stale value with no later corrector and the API-deployment sync-poll blocks to its full timeout. The codebase already uses the queryset-``.update()`` pattern for the same reason - in ``_set_result_acknowledge``; this pins it for the marker write too. + in ``set_result_acknowledge``; this pins it for the marker write too. """ def test_write_uses_queryset_update_not_save(self): diff --git a/backend/workflow_manager/workflow_v2/workflow_helper.py b/backend/workflow_manager/workflow_v2/workflow_helper.py index 2c926d0dcd..f50ba77713 100644 --- a/backend/workflow_manager/workflow_v2/workflow_helper.py +++ b/backend/workflow_manager/workflow_v2/workflow_helper.py @@ -420,7 +420,7 @@ def get_status_of_async_task( task_result = ResultCacheUtils.get_api_results( workflow_id=str(execution.workflow.id), execution_id=execution_id ) - cls._set_result_acknowledge(execution) + cls.set_result_acknowledge(execution) result_response = ExecutionResponse( workflow_id=str(execution.workflow.id), @@ -432,7 +432,7 @@ def get_status_of_async_task( return result_response @staticmethod - def _set_result_acknowledge(execution: WorkflowExecution) -> None: + def set_result_acknowledge(execution: WorkflowExecution) -> None: """Mark the result as acknowledged and update the database. This method is called once the task has completed and its result is forgotten. @@ -701,7 +701,7 @@ def execute_workflow_async( task_result = ResultCacheUtils.get_api_results( workflow_id=workflow_id, execution_id=execution_id ) - cls._set_result_acknowledge(workflow_execution) + cls.set_result_acknowledge(workflow_execution) else: task_result = None return ExecutionResponse( diff --git a/unstract/core/src/unstract/core/mime_gate.py b/unstract/core/src/unstract/core/mime_gate.py new file mode 100644 index 0000000000..d40b0b13e5 --- /dev/null +++ b/unstract/core/src/unstract/core/mime_gate.py @@ -0,0 +1,233 @@ +"""Shared rules for deciding whether a file may enter a workflow. + +Lives here because the backend and the workers must answer the same question +identically. Keep in sync with LLMWhisperer's supported types. +""" + +import re +import xml.etree.ElementTree as ElementTree +import zipfile + +import magic + +# libmagic reports these when it recognises a wrapper but not its contents, so +# they are never a verdict on their own. +RECOGNISED_WRAPPERS = frozenset({"application/zip", "application/x-ole-storage"}) +INCONCLUSIVE_MIME_TYPES = RECOGNISED_WRAPPERS | {"application/octet-stream"} + +# libmagic matches the PDF header only at offset 0, while extractors tolerate +# bytes before it, so a wrapped PDF needs the header looked for. +PDF_HEADER_SCAN_BYTES = 1024 + +# `%PDF-` alone appears in plenty of content that is not a PDF; a real header +# carries a version, so require one before promoting anything on its strength. +PDF_HEADER_PATTERN = re.compile(rb"%PDF-\d\.\d") + +# A package's declaration members are small; anything larger is not one. +PACKAGE_MEMBER_LIMIT = 65536 + +# Those members never carry a DTD, so refusing one keeps entity expansion away +# from the parser rather than trusting the parser to be safe about it. +DTD_MARKERS = (" str: + """A tag or attribute name without its namespace.""" + return tag.rsplit("}", 1)[-1] + + +def _attribute(element, name: str) -> str | None: + """An attribute by local name, whatever prefix it carries.""" + for key, value in element.attrib.items(): + if _local_name(key) == name: + return value.strip() + return None + + +def _read_member(archive: zipfile.ZipFile, name: str) -> bytes | None: + """A declaration member's bytes, bounded, or None if unusable.""" + try: + info = archive.getinfo(name) + if info.file_size > PACKAGE_MEMBER_LIMIT: + return None + with archive.open(info) as member: + return member.read(PACKAGE_MEMBER_LIMIT) + except (KeyError, OSError, zipfile.BadZipFile, ValueError): + return None + + +def _declares_dtd(raw: bytes) -> bool: + """Whether these bytes carry a DTD under any encoding the parser accepts.""" + for encoding in DTD_SEARCH_ENCODINGS: + text = raw.decode(encoding, "ignore").upper() + if any(marker in text for marker in DTD_MARKERS): + return True + return False + + +def _parse_member(archive: zipfile.ZipFile, name: str): + """Parse a declaration member, or None if it is unusable. + + Parsed rather than pattern-matched because the archive is untrusted: a + comment, a namespace prefix or an escaped attribute each defeat matching on + raw bytes, and each was a way past this check. + """ + raw = _read_member(archive, name) + if raw is None or _declares_dtd(raw): + return None + try: + return ElementTree.fromstring(raw) + except ElementTree.ParseError: + return None + + +def _normalise_part(part: str | None) -> str | None: + return part.lstrip("/") or None if part else None + + +def _declared_main_part(content_types) -> "tuple[str, str] | None": + """The part a package declares as its main one, and the type that implies.""" + for element in content_types.iter(): + if _local_name(element.tag) != "Override": + continue + mime_type = OOXML_MAIN_CONTENT_TYPES.get(_attribute(element, "ContentType") or "") + if not mime_type: + continue + part = _normalise_part(_attribute(element, "PartName")) + return (part, mime_type) if part else None + return None + + +def _relates_main_part(relationships, part: str) -> bool: + """Whether the package root relates `part` as its office document.""" + for element in relationships.iter(): + if _local_name(element.tag) != "Relationship": + continue + relation = _attribute(element, "Type") or "" + if not relation.rstrip("/").endswith(OFFICE_DOCUMENT_RELATION): + continue + if _normalise_part(_attribute(element, "Target")) == part: + return True + return False + + +def _identify_ooxml(archive: zipfile.ZipFile, names: "list[str]") -> str | None: + """Name an OOXML package, or None if the archive only looks like one. + + The package must declare its main part, relate that same part from the root, + and contain it. Any of those alone is satisfied by an archive the extractor + cannot open. + """ + content_types = _parse_member(archive, "[Content_Types].xml") + relationships = _parse_member(archive, "_rels/.rels") + if content_types is None or relationships is None: + return None + + declared = _declared_main_part(content_types) + if declared is None: + return None + part, mime_type = declared + if part not in names: + return None + return mime_type if _relates_main_part(relationships, part) else None + + +def _identify_odf(archive: zipfile.ZipFile, declared: str) -> str | None: + """Confirm the manifest agrees with what the mimetype member claims.""" + manifest = _parse_member(archive, "META-INF/manifest.xml") + if manifest is None: + return None + for element in manifest.iter(): + if _local_name(element.tag) != "file-entry": + continue + if ( + _attribute(element, "full-path") == "/" + and _attribute(element, "media-type") == declared + ): + return declared + return None + + +def identify_zip_container(path: str) -> str | None: + """Name the document inside a zip, or None if it is just a zip. + + Only ever returns a constant from this module, never a string read out of + the archive, so it widens what is recognised and not what is permitted. + """ + try: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + # ODF requires this member to be first and stored uncompressed, and + # its manifest to declare the same type for the package root. + info = archive.infolist()[0] if names else None + if ( + info is not None + and info.filename == "mimetype" + and info.compress_type == zipfile.ZIP_STORED + and info.file_size <= PACKAGE_MEMBER_LIMIT + ): + raw = _read_member(archive, "mimetype") or b"" + declared = raw.decode("ascii", "ignore").strip() + if declared in ODF_MIME_TYPES: + return _identify_odf(archive, declared) + if {"[Content_Types].xml", "_rels/.rels"} <= set(names): + return _identify_ooxml(archive, names) + except (zipfile.BadZipFile, OSError, KeyError, ValueError): + return None + return None + + +def resolve_inconclusive_mime_type(mime_type: str, head: bytes) -> str: + """Give a wrapper-only classification a second chance from the leading bytes. + + This promotes a file into the allow-list, so it stays narrow: a recognised + wrapper keeps its own answer, and the marker must look like a real header. + """ + if mime_type not in INCONCLUSIVE_MIME_TYPES: + return mime_type + # A wrapper libmagic positively identified keeps its own answer; only bytes + # it could not name at all are open to being read this way. + if mime_type in RECOGNISED_WRAPPERS: + return mime_type + match = PDF_HEADER_PATTERN.search(head[:PDF_HEADER_SCAN_BYTES]) + if match is None: + return mime_type + # Classified from the header's own offset rather than trusted as a substring. + return magic.from_buffer(head[match.start() :], mime=True) diff --git a/workers/api-deployment/tasks.py b/workers/api-deployment/tasks.py index 4d4b56b3b5..fa7a218d1a 100644 --- a/workers/api-deployment/tasks.py +++ b/workers/api-deployment/tasks.py @@ -218,7 +218,52 @@ def _unified_api_execution( converted_files = FileProcessingUtils.convert_file_hash_data(hash_values_of_files) if not converted_files: - logger.warning("No valid files to process after conversion") + # An empty result means either nothing was dispatched or every file + # failed to convert; only the first is a success. + if hash_values_of_files: + error_message = ( + f"None of the {len(hash_values_of_files)} dispatched files could " + "be converted for processing" + ) + logger.error(error_message) + api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.ERROR.value, + error_message=error_message, + total_files=len(hash_values_of_files), + successful_files=0, + failed_files=len(hash_values_of_files), + ) + if pipeline_id: + api_client.update_pipeline_status( + pipeline_id=pipeline_id, + status=PipelineStatus.FAILURE.value, + ) + return { + "execution_id": execution_id, + "status": "ERROR", + # "error", matching this function's other ERROR returns. + "error": error_message, + "files_processed": 0, + } + + logger.warning("No files dispatched for this execution") + # Without this write the row keeps its dispatch status and the caller + # polls forever. + api_client.update_workflow_execution_status( + execution_id=execution_id, + status=ExecutionStatus.COMPLETED.value, + total_files=0, + # Explicit, because NULL counters on a terminal row read as a + # clean success. + successful_files=0, + failed_files=0, + ) + if pipeline_id: + api_client.update_pipeline_status( + pipeline_id=pipeline_id, + status=PipelineStatus.SUCCESS.value, + ) return { "execution_id": execution_id, "status": "COMPLETED", diff --git a/workers/shared/enums/file_types.py b/workers/shared/enums/file_types.py index f49b7880e3..b29ee8e57d 100644 --- a/workers/shared/enums/file_types.py +++ b/workers/shared/enums/file_types.py @@ -7,6 +7,11 @@ from enum import Enum from typing import Any +from unstract.core.mime_gate import ( # noqa: F401 re-exported + INCONCLUSIVE_MIME_TYPES, + resolve_inconclusive_mime_type, +) + class AllowedFileTypes(Enum): """Allowed MIME types for file processing. @@ -47,18 +52,22 @@ class AllowedFileTypes(Enum): # Other formats CDFV2 = "application/CDFV2" - OCTET_STREAM = "application/octet-stream" @classmethod def is_allowed(cls, mime_type: str) -> bool: """Check if a MIME type is allowed for processing. + Any `text/*` passes, which is how html, xml, tsv, rtf and markdown reach + the extractor as text rather than as a listed format. + Args: mime_type: The MIME type string to validate Returns: bool: True if the MIME type is allowed, False otherwise """ + if mime_type.startswith("text/"): + return True return mime_type in cls._value2member_map_ diff --git a/workers/shared/workflow/execution/service.py b/workers/shared/workflow/execution/service.py index a62253d51c..ebf8bce17c 100644 --- a/workers/shared/workflow/execution/service.py +++ b/workers/shared/workflow/execution/service.py @@ -10,7 +10,7 @@ from typing import Any import magic -from shared.enums.file_types import AllowedFileTypes +from shared.enums.file_types import AllowedFileTypes, resolve_inconclusive_mime_type from shared.exceptions.execution_exceptions import ( NotFoundDestinationConfiguration, NotFoundSourceConfiguration, @@ -1219,7 +1219,9 @@ def _copy_filesystem_file( while chunk := source_file.read(self.READ_CHUNK_SIZE): # MIME type detection and validation on first chunk if first_chunk: - mime_type = magic.from_buffer(chunk, mime=True) + mime_type = resolve_inconclusive_mime_type( + magic.from_buffer(chunk, mime=True), chunk + ) logger.info(f"Detected MIME type: {mime_type} for file {file_path}") if not AllowedFileTypes.is_allowed(mime_type):