Skip to content

UN-4574 [FIX] Classify files the way LLMWhisperer does so ERP xlsx exports are accepted - #2425

Open
Deepak-Kesavan wants to merge 10 commits into
mainfrom
UN-4574-erp-xlsx-mime-gate
Open

Deepak-Kesavan wants to merge 10 commits into
mainfrom
UN-4574-erp-xlsx-mime-gate

Conversation

@Deepak-Kesavan

@Deepak-Kesavan Deepak-Kesavan commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Unstract's file-type gate now classifies files with the same steps as LLMWhisperer's (Util.is_valid_file_type_from_path in unstract-llm-whisperer), so a file LLMWhisperer would accept is no longer rejected here.
  • API deployment uploads, Prompt Studio uploads and FileValidator classify the whole file. Connector/ETL copies (backend and workers) check the first chunk and classify the whole file locally only when that check is unclear.

Why

  • A customer's ERP (SAP) xlsx exports were rejected with unsupported MIME type 'application/octet-stream', while LLMWhisperer extracted the same file fine (UN-4574). Re-saving in Excel "fixed" it.
  • The export's first zip member is a leftover [trash]/0002.dat part. For that layout libmagic describes the file as "Microsoft OOXML" but reports MIME application/octet-stream, not application/zip.
  • Our gate only looked inside the zip when libmagic said exactly application/zip, so the file stopped at octet-stream. LLMWhisperer asks Magika after an octet-stream or zip verdict, and Magika says xlsx.
  • The connector paths and Prompt Studio only ever sniffed a prefix, so they rejected the same file.

How

  • unstract/core/mime_gate.py:
    • classify_file_path(path) copies LLMWhisperer's gate step by step: magic.from_file on the whole file → for octet-stream/zip, a second look via our zip reader (identify_zip_container, standing in for Magika, which we deliberately don't add as a dependency) → for octet-stream/zip still, LLMWhisperer's %PDF- scan of the first 1024 bytes.
    • classify_stream(stream, chunk_size) is for connector copies. A first chunk libmagic can name decides on its own. An unclear one (octet-stream/zip/OLE2) is copied to a local temp file and classified whole. The copy is capped at LLMWhisperer's default MAX_FILE_SIZE (300 MB); larger files keep the first-chunk verdict and are rejected, as LLMWhisperer would reject them anyway. The bytes are handed back for the copy, so nothing is read twice from the source.
    • Removed resolve_inconclusive_mime_type, which was stricter than LLMWhisperer: it needed %PDF-x.y, re-checked with libmagic, and skipped zips.
  • backend/utils/upload_mime_type.py: detect_upload_mime_type classifies an upload from its temp file, or copies it to one first. A temp dir that can't be written raises UploadSpillError (a RuntimeError), so it fails the request instead of being reported as a bad file.
  • Callers switched over: the API staging loop and connector copy in endpoint_v2/source.py, the worker connector copy in workers/shared/workflow/execution/service.py, FileValidator (which now checks size before type), and Prompt Studio's upload view.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • More files are accepted, by design. This PR copies LLMWhisperer's %PDF- rule exactly: any file libmagic reports as octet-stream or zip with %PDF- in its first 1024 bytes is accepted as a PDF. That includes a zip with an uncompressed entry containing the marker. LLMWhisperer accepts these too, so they don't fail later in extraction.
  • Known remaining gap: without Magika, LLMWhisperer can still name some octet-stream/zip files that we reject. It never goes the other way: we don't accept anything LLMWhisperer refuses.
  • Local disk use: every upload under Django's in-memory limit (2.5 MB) is now copied to a temp file to be classified. Connector files whose first chunk is unclear are copied to the pod's /tmp, up to 300 MB. A legacy .doc/.xls or an unclear binary in an ETL source now costs a local copy where it used to be judged from its first 4 MB.
  • Error order: FileValidator now reports an oversized file as too large before checking its type.
  • Removed symbols (resolve_inconclusive_mime_type, and the INCONCLUSIVE_MIME_TYPES re-exports from workflow_manager.endpoint_v2.enums and shared.enums.file_types) have no users in unstract or unstract-cloud main.

Database Migrations

  • None

Env Config

  • None

Relevant Docs

  • LLMWhisperer gate: unstract-llm-whisperer → backend/app/util/base.py Util.is_valid_file_type_from_path

Related Issues or PRs

Dependencies Versions

  • No new dependencies.

Notes on Testing

  • Unit tests: backend/workflow_manager/endpoint_v2/tests/test_api_storage_mime_validation.py: 56 passed. These include an ERP-shaped xlsx (synthetic, built in the test) through the API staging loop and through the backend connector copy, and a full temp dir failing the request. workers/tests/test_mime_gate.py: 9 passed (classifier, size cap, and the worker connector copy across multiple chunks).
  • The customer's original file (not committed) was checked in python:3.12-slim-trixie (libmagic 5.46, the backend's base image). Before: octet-stream → rejected. After: classify_file_path and classify_stream both return xlsx, and the bytes come through unchanged.
  • Dev env (deepak-unstract-dev), before this change: the original ERP file sent to an API deployment → Rejecting file 'orig.xlsx' with unsupported MIME type 'application/octet-stream'; control workbook → Success.
  • Dev env, after this change (snapshot.1793689200): the same original ERP file → Success (passed the gate and was extracted by LLMWhisperer); control workbook → Success. The ERP file was re-sent once a minute, and the result switched from rejection to Success when the new backend pod started serving. Prompt Studio upload in the UI not yet checked.

Screenshots

Checklist

I have read and understood the Contribution Guidelines.

…exports are accepted

ERP exports whose first zip member is a leftover `[trash]/*.dat` part make
libmagic report application/octet-stream for a valid xlsx. The gate only
looked inside a zip when libmagic said application/zip, so these files were
rejected while LLMWhisperer (which asks a second classifier) accepted them.

mime_gate.classify_file_path now follows LLMWhisperer's gate step by step:
whole-file libmagic, then a second look for octet-stream/zip (our zip reader
standing in for Magika), then LLMWhisperer's `%PDF-` scan of the first 1024
bytes. Uploads (API deployments, Prompt Studio, FileValidator) are classified
from the whole file; connector copies in the backend and workers sniff the
first chunk and classify the whole file locally only when it is inconclusive.
@greptile-apps

greptile-apps Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the previous ZIP error finding is fixed and no new blocking issue was found.

Summary

The PR shares whole-file MIME checks across uploads and connector copies so ERP-shaped Excel files can pass the gate.

  • Uploads now identify ERP workbooks from their complete contents.
  • Connector copies now classify unclear files from a bounded local copy.

Reviews (6) · Last reviewed commit: "Merge remote-tracking branch 'origin/UN-..." · Reviewed by Greptile

Comment thread backend/utils/upload_mime_type.py Resolved
@Deepak-Kesavan Deepak-Kesavan self-assigned this Nov 3, 2026
@sonarqubecloud

sonarqubecloud Bot commented Nov 5, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
⚪ e2e-agent-kv e2e 0 0 0 1 0.1
✅ e2e-api-deployment e2e 3 0 0 0 11.7
✅ e2e-coowners e2e 1 0 0 0 1.2
✅ e2e-etl e2e 1 0 0 0 11.8
✅ e2e-login e2e 2 0 0 0 0.8
✅ e2e-prompt-studio e2e 1 0 0 0 14.3
✅ e2e-smoke e2e 2 0 0 0 1.5
✅ e2e-workflow e2e 1 0 0 0 13.5
✅ frontend unit 882 0 0 0 18.3
✅ integration-backend integration 1557 0 0 26 65.7
✅ integration-connectors integration 1 0 0 7 7.1
✅ integration-workers integration 181 0 0 1 44.0
❌ ui e2e 0 1 0 0 0.0
✅ unit-agent-kv-schema unit 143 0 0 0 1.3
✅ unit-backend unit 2168 0 0 1 47.2
✅ unit-connectors unit 82 0 0 0 8.6
✅ unit-core unit 281 0 0 0 2.7
✅ unit-filesystem unit 1 0 0 0 3.2
✅ unit-platform-service unit 22 0 0 0 2.2
✅ unit-rig unit 120 0 0 0 3.5
✅ unit-runner unit 12 0 0 0 2.2
✅ unit-sdk1 unit 1356 0 0 0 35.7
✅ unit-tool-sidecar unit 8 0 0 0 1.4
✅ unit-workers unit 1677 0 0 2 116.6
TOTAL 8502 1 0 38 414.6

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants