Switch stage-3 triage from NUL sniff to UTF-8 validity

The NUL-byte heuristic misfiled any non-ASCII UTF-8 text (accents, CJK,
emoji) as binary and let non-NUL control bytes through as text. is_binary
now calls a file text when its 8000-byte sniff window is valid UTF-8 with
no control bytes outside the text-safe set (tab/newline/CR/ESC/etc).

- trim_truncated_utf8 drops a multi-byte char split by the window edge so
  it isn't mistaken for malformed bytes.
- NUL still classifies as binary (valid UTF-8 scalar, non-text control).
- Expanded tests: Unicode, ANSI logs, stray control byte, malformed UTF-8,
  boundary-split char; updated README stage-3 description.
This commit is contained in:
2026-07-02 17:01:44 -04:00
parent e42e8ef8af
commit 9fd1bf385b
3 changed files with 94 additions and 18 deletions

View File

@@ -141,24 +141,54 @@ end
end
end
@testset "is_binary: NUL-byte sniff" begin
@testset "is_binary: UTF-8 sniff" begin
mktempdir() do root
# Plain text → text.
# Plain ASCII text → text.
txt = joinpath(root, "notes.txt")
write(txt, "hello, world\nsecond line\n")
@test is_binary(txt) == false
# A NUL byte anywhere in the sniff window → binary.
# Non-ASCII UTF-8 (accents, CJK, emoji) is valid text — the whole
# point of moving off the printable-ASCII/NUL heuristic.
uni = joinpath(root, "unicode.txt")
write(uni, "café — 日本語 — 🚀\n")
@test is_binary(uni) == false
# ANSI-colored log: ESC + other text control bytes are text-safe.
ansi = joinpath(root, "colored.log")
write(ansi, "\e[31merror\e[0m: tab\there\r\nnext\n")
@test is_binary(ansi) == false
# A NUL byte anywhere in the sniff window → binary (it's a control
# byte outside the text-safe set, even though it's valid UTF-8).
bin = joinpath(root, "blob.dat")
write(bin, UInt8[0x01, 0x02, 0x00, 0x03])
@test is_binary(bin) == true
# Empty file has no NUL → treated as text.
# A non-NUL, non-text control byte (e.g. 0x07 BEL) → binary.
ctrl = joinpath(root, "ctrl.dat")
write(ctrl, UInt8[UInt8('h'), UInt8('i'), 0x07])
@test is_binary(ctrl) == true
# Malformed UTF-8 (lone continuation / bad lead byte) → binary.
bad = joinpath(root, "bad.dat")
write(bad, UInt8[UInt8('a'), 0xff, 0xfe, 0xc3, 0x28])
@test is_binary(bad) == true
# A multi-byte char split by the sniff boundary must NOT read as
# binary: pad to one byte short of the window, then a 2-byte 'é'
# (0xc3 0xa9) so only its lead byte lands inside the window.
split = joinpath(root, "split.txt")
write(split, vcat(fill(UInt8('a'), FileServer.CONTENT_SNIFF_BYTES - 1),
UInt8[0xc3, 0xa9]))
@test is_binary(split) == false
# Empty file → treated as text.
empty = joinpath(root, "empty")
write(empty, UInt8[])
@test is_binary(empty) == false
# A NUL past the sniff window is not seen → still text.
# Binary garbage past the sniff window is not seen → still text.
far = joinpath(root, "far.txt")
write(far, vcat(fill(UInt8('a'), FileServer.CONTENT_SNIFF_BYTES), UInt8[0x00]))
@test is_binary(far) == false