Skip to content

fix: strip leading UTF-8 BOM before JSON content-shape detection - #4397

Open
chuenchen309 wants to merge 2 commits into
Unstructured-IO:mainfrom
chuenchen309:chuenchen309/fix-json-bom-misdetection
Open

fix: strip leading UTF-8 BOM before JSON content-shape detection#4397
chuenchen309 wants to merge 2 commits into
Unstructured-IO:mainfrom
chuenchen309:chuenchen309/fix-json-bom-misdetection

Conversation

@chuenchen309

@chuenchen309 chuenchen309 commented Jul 14, 2026

Copy link
Copy Markdown

Problem

detect_filetype() decodes a file's text head as plain "utf-8". If the source has a leading UTF-8 byte-order-mark, that BOM survives the decode as a literal U+FEFF character at the start of the text.

_TextFileDifferentiator._is_json requires the first non-whitespace character to be [ or {:

if text_head.lstrip()[0] not in "[{":
    return False

str.lstrip() doesn't strip U+FEFF (it's not whitespace), so this check fails immediately for BOM-prefixed content — _is_json returns False without even attempting to parse it. When this happens for a payload with no .json/.ndjson extension and libmagic guesses a generic text/plain MIME type (which it does for BOM-prefixed content — confirmed BOM changes libmagic's own guess from application/json/application/x-ndjson to text/plain), the file falls all the way through to FileType.TXT instead of FileType.JSON.

Repro

import io, json
from unstructured.file_utils.filetype import detect_filetype

json_bytes = b"\xef\xbb\xbf" + json.dumps([{"example": "data"}]).encode("utf-8")
detect_filetype(file=io.BytesIO(json_bytes), metadata_file_path="filename.pdf")
# -> FileType.TXT  (before this fix)
# -> FileType.JSON (after this fix, matching the non-BOM case)

Fix

Strip a leading BOM once in _FileTypeDetectionContext.text_head (the property consumed by _is_json/_is_csv/_is_eml), rather than changing the .encoding property itself — an existing test (it_knows_the_encoding_asserted_by_the_caller_and_normalizes_it) asserts .encoding returns the literal string "utf-8", so I kept that contract untouched and fixed the actual text consumption point instead.

Per the contribution checklist, bumped unstructured/__version__.py to 0.25.1 and added a CHANGELOG.md entry.

Testing

  • Added test_it_identifies_json_with_a_leading_utf8_bom_and_no_extension to test_unstructured/file_utils/test_filetype.py; confirmed it fails against the pre-fix code (FileType.TXT instead of FileType.JSON, verified via git stash) and passes with the fix.
  • Ran the full test_unstructured/file_utils/ suite (380 passed, 1 xfailed — no regressions) plus test_json.py/test_ndjson.py/test_csv.py/test_text.py (189 passed) since those partitioners consume text_head indirectly through detection.
  • ruff check / ruff format --check clean on all changed files; scripts/version-sync.sh -c confirms the version bump is consistent.

Disclosure: I used an AI coding assistant (Claude) to help identify this bug and draft the fix. The initial hypothesis about the exact failure mechanism was actually wrong (it pointed at a different function than the true cause), so I traced through _FileTypeDetector's multi-strategy dispatch myself to find where the fix needed to land, wrote/verified the regression test in both red and green states, and ran the broader test suites above before opening this PR.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread unstructured/file_utils/filetype.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Shadow auto-approve: would auto-approve. Corrects UTF-8 BOM handling in file type detection with a targeted, low-risk change (strip one character) and adds a test. No core logic, infrastructure, or security impact.

Re-trigger cubic

chuenchen309 and others added 2 commits July 16, 2026 05:58
detect_filetype() decoded a file's text head as plain "utf-8", leaving
a leading byte-order-mark as a literal U+FEFF character. This defeated
_TextFileDifferentiator._is_json's content-shape check (which requires
the first non-whitespace character to be `[` or `{`), so a BOM-prefixed
JSON payload with no .json/.ndjson extension and a generic text/plain
libmagic guess fell through to FileType.TXT instead of FileType.JSON.

Strip the leading BOM once in _FileTypeDetectionContext.text_head
(consumed by _is_json/_is_csv/_is_eml) rather than at the `.encoding`
property, since an existing test asserts .encoding returns the literal
string "utf-8"/"utf-8-sig" is otherwise byte-identical to "utf-8" for
non-BOM content, so this only changes behavior when a BOM is present.

Bumped __version__.py to 0.25.1 and added a CHANGELOG.md entry per the
contribution checklist.

Disclosure: I used an AI coding assistant (Claude) to help identify
this bug and draft the fix. I independently verified the actual bug
mechanism (it required tracing through _FileTypeDetector's multi-strategy
dispatch, not just the initial hypothesis), wrote/ran the regression
test in both red and green states, ran the broader file_utils and
json/ndjson/csv/text partition suites (no regressions), and ran
ruff check/format before opening this PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses cubic-dev-ai review feedback on this PR: `text.lstrip("")`
strips ALL leading U+FEFF characters, not just the one UTF-8 BOM that a
plain "utf-8" decode can ever produce. A second (or later) U+FEFF
immediately following the real BOM is legitimate content and should be
preserved, not silently dropped.

How verified: added a regression test constructing a file with two
leading BOM byte-sequences; confirmed it fails before the fix
(text_head == "{}" instead of "{}") and passes after. Full
test_filetype.py suite (271 passed, 1 pre-existing xfail) still green.
ruff check/format clean via `make check`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chuenchen309
chuenchen309 force-pushed the chuenchen309/fix-json-bom-misdetection branch from 143723e to 5cbac58 Compare July 15, 2026 21:58
chuenchen309 added a commit to chuenchen309/unstructured that referenced this pull request Jul 15, 2026
…letype()

_TextFileDifferentiator._is_json calls json.load() directly on the raw
file bytes when libmagic guesses a generic text/plain MIME type and the
content starts with `[`/`{`. If the bytes aren't valid UTF-8, json.load()
raises UnicodeDecodeError (via its internal detect_encoding step), which
wasn't caught by the existing `except json.JSONDecodeError`, so it
propagated straight out of the public detect_filetype() API instead of
falling back to FileType.TXT like any other undetectable content.

Broaden the except clause to also catch UnicodeDecodeError.

Added a regression test with non-UTF-8 bytes wrapped in braces and no
file extension, confirming detect_filetype() returns FileType.TXT
instead of raising. Confirmed it fails against the pre-fix code and
passes after. Ran the full test_unstructured/file_utils/ suite (380
passed, 1 xfailed -- no regressions), ruff check/format, and bumped
__version__.py + CHANGELOG.md per the contribution checklist (note:
this may create a merge conflict with my other open PR Unstructured-IO#4397, which
also bumps to 0.25.1 from the same base -- happy to rebase whichever
merges second).

Disclosure: I used an AI coding assistant (Claude) to help identify
this bug and draft the fix. I independently reproduced the
UnicodeDecodeError crash before writing the test and ran the full
local test suite before opening this PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant