From 3b36a48b7ce5a53a9366f3b31f94311f178e2553 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 01:22:12 +0200 Subject: krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/extract/plain.go | 282 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 internal/extract/plain.go (limited to 'internal/extract/plain.go') diff --git a/internal/extract/plain.go b/internal/extract/plain.go new file mode 100644 index 0000000..d245a41 --- /dev/null +++ b/internal/extract/plain.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package extract + +import ( + "bytes" + "encoding/binary" + "html" + "io" + "os" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// sniffSize is how much of an unknown-extension file is inspected to guess +// whether it is text (design.md §6). +const sniffSize = 8192 + +// readDecoded reads path whole and decodes it per the encoding rules: a +// leading UTF-8 BOM is stripped and the rest used as is; a UTF-16 LE or BE +// BOM is decoded with unicode/utf16; otherwise valid UTF-8 is used as is, +// and any other invalid UTF-8 is decoded one byte per Latin-1 code point. +func readDecoded(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return decode(data), nil +} + +// sniffText decides whether an unknown extension is text, reading at most +// sniffSize bytes before deciding: a file whose first sniffSize bytes are +// not a UTF-16 BOM and not valid-UTF8-with-no-NUL is rejected as +// ErrUnsupported without reading any further (so a large binary file is +// never read in full just to be rejected). Only once that sample passes is +// the rest of the file read; a UTF-16 BOM is trusted as plain text from +// the sample alone (UTF-16 text is full of NUL bytes by design), but a +// sample that merely looks like UTF-8 must hold for the WHOLE file — no +// NUL byte anywhere, and no invalid UTF-8 anywhere past the sample — or +// the file is ErrUnsupported after all; the Latin-1 fallback in decode +// never applies to a sniffed file, only to a file whose extension already +// names it as text. D2: when the file continues past the sample (n == +// sniffSize), the validity check is run against a trimmed copy with any +// incomplete trailing rune removed, so a multi-byte rune that happens to +// straddle byte sniffSize does not make an otherwise-valid file sniff as +// unsupported; sample itself, used below to build the returned text, is +// left untouched — the rest of the file (read after the check) supplies +// the bytes trimming set aside. +func sniffText(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + sample := make([]byte, sniffSize) + n, err := io.ReadFull(f, sample) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "", err + } + sample = sample[:n] + + check := sample + if n == sniffSize { + check = trimIncompleteTrailingRune(sample) + } + + utf16BOM := hasUTF16BOM(sample) + if !utf16BOM && !(utf8.Valid(check) && !bytes.Contains(check, []byte{0})) { + return "", ErrUnsupported + } + + rest, err := io.ReadAll(f) + if err != nil { + return "", err + } + data := append(sample, rest...) + + if utf16BOM { + return decode(data), nil + } + if !utf8.Valid(data) || bytes.Contains(data, []byte{0}) { + return "", ErrUnsupported + } + return decode(data), nil +} + +// trimIncompleteTrailingRune drops an incomplete UTF-8 sequence left +// dangling at the very end of b — D2's fix for a rune cut off exactly at +// the sniff sample's boundary. It looks back at most utf8.UTFMax-1 bytes +// for the start of the trailing rune; if the bytes from there to the end +// are not a complete encoding (utf8.FullRune), that partial rune is cut, +// since more bytes to finish it may simply not have been read yet. A +// sample already ending cleanly (the common case, and every all-ASCII +// sample) is returned unchanged. +func trimIncompleteTrailingRune(b []byte) []byte { + end := len(b) + start := end - 1 + for start >= 0 && end-start < utf8.UTFMax && !utf8.RuneStart(b[start]) { + start-- + } + if start < 0 || utf8.FullRune(b[start:end]) { + return b + } + return b[:start] +} + +// hasUTF16BOM reports whether b begins with a UTF-16 little- or big-endian +// byte-order mark. +func hasUTF16BOM(b []byte) bool { + return len(b) >= 2 && ((b[0] == 0xFF && b[1] == 0xFE) || (b[0] == 0xFE && b[1] == 0xFF)) +} + +// decode applies the encoding rules to a whole file's bytes. +func decode(data []byte) string { + switch { + case len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE: + return decodeUTF16(data[2:], binary.LittleEndian) + case len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF: + return decodeUTF16(data[2:], binary.BigEndian) + case len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF: + return string(data[3:]) + case utf8.Valid(data): + return string(data) + default: + return decodeLatin1(data) + } +} + +// decodeUTF16 decodes b (already past the BOM) as UTF-16 in the given byte +// order; a trailing odd byte with no pair is dropped. +func decodeUTF16(b []byte, order binary.ByteOrder) string { + n := len(b) / 2 + units := make([]uint16, n) + for i := 0; i < n; i++ { + units[i] = order.Uint16(b[i*2 : i*2+2]) + } + return string(utf16.Decode(units)) +} + +// decodeLatin1 decodes b as Latin-1: each byte is its own Unicode code +// point. +func decodeLatin1(b []byte) string { + r := make([]rune, len(b)) + for i, c := range b { + r[i] = rune(c) + } + return string(r) +} + +// stripMarkup turns decoded HTML/XML/SVG text into plain text: a small +// scanner replaces every <...> tag with a space, dropping the contents of +//