fix: stop misclassifying multibyte UTF-8 text files as binary in read_file - #80250
fix: stop misclassifying multibyte UTF-8 text files as binary in read_file#802500809android wants to merge 1 commit into
Conversation
read_file samples the first 1000 bytes (head -c 1000) for binary detection. When the byte boundary cuts through a multibyte UTF-8 character, the terminal backend's incremental decoder (errors=replace) turns the dangling bytes into U+FFFD at the END of the sample. _is_likely_binary then treats any U+FFFD as mojibake evidence and classifies the file as binary — so valid Japanese/Chinese/Korean text files frequently cannot be read. Strip a trailing run of <=3 U+FFFD (a 4-byte char yields at most 3 dangling bytes) before judging. Genuine mojibake (U+FFFD throughout the sample) is still flagged binary, preserving the read-only guard.
|
Fixes #80251 |
monerostar
left a comment
There was a problem hiding this comment.
Ubuntu 26.04 on linux-5800x (kernel 7.0.0-28-generic).
Live ShellFileOperations.read_file on a 1200-byte Japanese hiragana file (あ x 400). Byte 1000 lands mid-character so head -c 1000 yields a trailing U+FFFD in the sample.
| fixture | main | this PR |
|---|---|---|
| jp.txt (multibyte cut) | is_binary=True | is_binary=False, content_len=402 |
| ascii.txt | text OK | text OK |
| nul.bin | binary | binary |
| bytes(range(256))*4 | binary | binary |
Also: _is_likely_binary on that sample is True on main, False on PR. tests/tools/test_file_operations.py 49 passed here.
Looks good. The trailing ≤3 FFFD carve-out matches the real head -c cut; real binary still blocked.
|
Triage note: this appears to duplicate three other PRs that fix the same issue — UTF-8 files whose 1000-byte read_file sample lands mid-multibyte-char (trailing U+FFFD) get misclassified as binary. Same files (tools/file_operations.py + tests), same fix. The cluster: #80186, #80188, #80261. Recommend picking one to champion and closing the rest as duplicates. |
…ort-lossy text Fixes the read_file half of #80308 and the class behind #80261, #80250, The binary sniff sampled files via 'head -c 1000' through the terminal transport, which decodes stdout with errors="replace". A multibyte character cut at byte 1000 therefore arrived as U+FFFD, and _is_likely_binary treated any U+FFFD as binary — flagging valid CJK and emoji text as unreadable. At the text layer a stored replacement char and a transport-manufactured one are indistinguishable, which is why per-callsite adjustments kept leaving siblings open. Sample as 'head -c 1000 | base64' so raw bytes survive the transport (fail-open to the legacy heuristic when the transport cannot produce clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing one incomplete multibyte sequence at the sample end => text; mid-stream invalid UTF-8 (latin-1, true binaries) => read-only, preserving the anti-mojibake guarantee the old check existed for. Files legitimately containing U+FFFD become readable.
|
Closing — superseded by #81961, which fixed this bug class (1000-byte sample cutting a multibyte char → binary false positive) at the byte layer across read_file, patch, and search_files, with regression tests for truncated-CJK/BOM/UTF-16/NUL cases. ~24 PRs raced on this one; earliest diagnosis credit to @webtecnica (#76924), merged implementation from @ayushnangia (#80440). Thanks for jumping on it. |
…ort-lossy text Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250, The binary sniff sampled files via 'head -c 1000' through the terminal transport, which decodes stdout with errors="replace". A multibyte character cut at byte 1000 therefore arrived as U+FFFD, and _is_likely_binary treated any U+FFFD as binary — flagging valid CJK and emoji text as unreadable. At the text layer a stored replacement char and a transport-manufactured one are indistinguishable, which is why per-callsite adjustments kept leaving siblings open. Sample as 'head -c 1000 | base64' so raw bytes survive the transport (fail-open to the legacy heuristic when the transport cannot produce clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing one incomplete multibyte sequence at the sample end => text; mid-stream invalid UTF-8 (latin-1, true binaries) => read-only, preserving the anti-mojibake guarantee the old check existed for. Files legitimately containing U+FFFD become readable.
…ort-lossy text Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250, The binary sniff sampled files via 'head -c 1000' through the terminal transport, which decodes stdout with errors="replace". A multibyte character cut at byte 1000 therefore arrived as U+FFFD, and _is_likely_binary treated any U+FFFD as binary — flagging valid CJK and emoji text as unreadable. At the text layer a stored replacement char and a transport-manufactured one are indistinguishable, which is why per-callsite adjustments kept leaving siblings open. Sample as 'head -c 1000 | base64' so raw bytes survive the transport (fail-open to the legacy heuristic when the transport cannot produce clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing one incomplete multibyte sequence at the sample end => text; mid-stream invalid UTF-8 (latin-1, true binaries) => read-only, preserving the anti-mojibake guarantee the old check existed for. Files legitimately containing U+FFFD become readable.
…ort-lossy text Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250, The binary sniff sampled files via 'head -c 1000' through the terminal transport, which decodes stdout with errors="replace". A multibyte character cut at byte 1000 therefore arrived as U+FFFD, and _is_likely_binary treated any U+FFFD as binary — flagging valid CJK and emoji text as unreadable. At the text layer a stored replacement char and a transport-manufactured one are indistinguishable, which is why per-callsite adjustments kept leaving siblings open. Sample as 'head -c 1000 | base64' so raw bytes survive the transport (fail-open to the legacy heuristic when the transport cannot produce clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing one incomplete multibyte sequence at the sample end => text; mid-stream invalid UTF-8 (latin-1, true binaries) => read-only, preserving the anti-mojibake guarantee the old check existed for. Files legitimately containing U+FFFD become readable.
…ort-lossy text Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250, The binary sniff sampled files via 'head -c 1000' through the terminal transport, which decodes stdout with errors="replace". A multibyte character cut at byte 1000 therefore arrived as U+FFFD, and _is_likely_binary treated any U+FFFD as binary — flagging valid CJK and emoji text as unreadable. At the text layer a stored replacement char and a transport-manufactured one are indistinguishable, which is why per-callsite adjustments kept leaving siblings open. Sample as 'head -c 1000 | base64' so raw bytes survive the transport (fail-open to the legacy heuristic when the transport cannot produce clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing one incomplete multibyte sequence at the sample end => text; mid-stream invalid UTF-8 (latin-1, true binaries) => read-only, preserving the anti-mojibake guarantee the old check existed for. Files legitimately containing U+FFFD become readable.
Summary
read_file(andread_file_raw) classify valid UTF-8 text files as binary when the 1000-byte sampling boundary (head -c 1000) cuts through a multibyte character. Japanese, Chinese, Korean, and emoji-heavy text files are affected frequently (3-byte UTF-8 chars make a mid-character cut highly likely).Reproduction
Any UTF-8 text file whose byte 1000 lands inside a multibyte char, e.g. a Japanese markdown file > 1000 bytes.
read_filereturns:Verified against a real 10 KB Japanese file:
head -c 1000cut"自"(\xe5\xbe\xaa) after its 2nd byte, the incremental decoder (errors="replace") produced one trailing U+FFFD at sample position 386, and_is_likely_binaryreturned True.Root cause
read_filesamples the first 1000 bytes (head -c 1000) for binary detection.codecs.getincrementaldecoder("utf-8")(errors="replace")(tools/environments/base.py), so the dangling bytes of a cut multibyte char arrive as U+FFFD at the end of the sample._is_likely_binarytreats any U+FFFD in the sample as evidence of mojibake → binary. That guard exists to prevent read→edit→write corruption of genuinely non-UTF-8 files, but it does not distinguish a truncation artifact (1–3 trailing U+FFFD) from genuine mojibake (U+FFFD throughout).Fix
In
_is_likely_binary, strip a trailing run of ≤3 U+FFFD from the sample before judging. A 4-byte UTF-8 char split across the cut yields at most 3 dangling bytes, so a longer trailing run (≥4) still counts as binary (safe direction). Genuine mojibake (U+FFFD in the middle of the sample) is still flagged binary, preserving the corruption guard.Tests
Added 3 regression tests to
tests/tools/test_file_operations.py(TestReadNonUtf8IsBinary):Full file: 49 passed.