Skip to content

Valid UTF-8 files are wrongly reported as [Binary file] when a multi-byte character straddles the 1024-byte read boundary #594

Description

@kotophalk

Summary

A fully valid UTF-8 source file gets its content replaced with [Binary file] whenever a multi-byte UTF-8 character happens to start within the last few bytes of the first 1024 bytes (_CHUNK_SIZE). The binary/text detection only reads and decodes the first chunk, so a multi-byte character split across the chunk boundary raises UnicodeDecodeError and the whole file is misclassified as binary.

The file itself is perfectly valid UTF-8 — git, editors, and python all read it without any issue.

Environment

  • gitingest version: 0.3.1 (also reproducible on gitingest.com)
  • Python: 3.12
  • OS: Linux

Steps to reproduce

Create a UTF-8 file where a multi-byte character (e.g. a Cyrillic letter, an em dash, an emoji) begins at byte offset 1023, so its continuation byte lands at offset 1024 — just outside the chunk:

# make a repro file: 1023 ASCII bytes of padding, then a 2-byte Cyrillic 'ч' (D1 87)
data = b"# " + b"x" * 1021 + "ч".encode("utf-8") + b"\nprint('hello')\n"
with open("repro.py", "wb") as f:
    f.write(data)

# sanity check: the file is valid UTF-8
open("repro.py", encoding="utf-8").read()   # succeeds, no error

Then run gitingest on a repo containing this file. The output for repro.py is:

================================================
FILE: repro.py
================================================
[Binary file]

Root cause

Detection reads only the first _CHUNK_SIZE bytes and decodes that raw slice:

src/gitingest/utils/file_utils.py

_CHUNK_SIZE = 1024  # bytes

def _read_chunk(path: Path) -> bytes | None:
    ...
    return fp.read(_CHUNK_SIZE)

src/gitingest/schemas/filesystem.py

if not _decodes(chunk, "utf-8"):
    return "[Binary file]"

_decodes calls chunk.decode("utf-8"), which raises UnicodeDecodeError when the chunk ends in the middle of a multi-byte sequence:

'utf-8' codec can't decode byte 0xd1 in position 1023: unexpected end of data

Because the slice is cut at an arbitrary byte offset, a valid multi-byte character can be truncated even though the file is fully valid UTF-8. The classification is therefore position-dependent and fires on legitimate text files that contain any non-ASCII content near the boundary (comments/strings in Cyrillic, CJK, accented Latin, em dashes, emoji, etc.).

Expected behavior

Valid UTF-8 files should always be treated as text, regardless of where non-ASCII characters fall relative to the 1024-byte read window.

Suggested fix

Don't let an incomplete trailing multi-byte sequence in the chunk cause a false negative. A few options:

  1. Decode the chunk incrementally so a truncated tail is tolerated:

    import codecs
    
    def _decodes(chunk: bytes, encoding: str) -> bool:
        decoder = codecs.getincrementaldecoder(encoding)()
        try:
            decoder.decode(chunk, final=False)  # don't force-finish the last char
        except UnicodeDecodeError:
            return False
        return True
  2. Or read the chunk on a character boundary (e.g. io.TextIOWrapper/fp.read(n) in text mode with the candidate encoding), so no character is ever split.

  3. Or, for the specific "unexpected end of data" case, retry after trimming the incomplete trailing bytes before deciding the file is binary.

Option 1 is the smallest change and keeps the existing chunk-based approach.

Additional notes

  • The presence of a NUL byte remains a legitimate binary signal; this fix only addresses the truncated-multibyte false positive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions