aboutsummaryrefslogtreecommitdiff
path: root/internal/extract
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/extract
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/extract')
-rw-r--r--internal/extract/extract.go154
-rw-r--r--internal/extract/plain.go282
-rw-r--r--internal/extract/plain_test.go229
-rw-r--r--internal/extract/tools.go246
-rw-r--r--internal/extract/tools_test.go239
-rw-r--r--internal/extract/zipxml.go224
-rw-r--r--internal/extract/zipxml_test.go205
7 files changed, 1579 insertions, 0 deletions
diff --git a/internal/extract/extract.go b/internal/extract/extract.go
new file mode 100644
index 0000000..aa3ae93
--- /dev/null
+++ b/internal/extract/extract.go
@@ -0,0 +1,154 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package extract gets text out of files so rules can test their content.
+// Text is returned raw; callers normalise it with norm.Text.
+package extract
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var (
+ // ErrUnsupported is returned when the format carries no text krino
+ // knows how to extract.
+ ErrUnsupported = errors.New("no text in this format")
+ // ErrTooLarge is returned when the file is larger than the configured
+ // max-read; nothing is read in that case.
+ ErrTooLarge = errors.New("larger than max-read")
+)
+
+// ToolMissingError is returned when a format needs an external tool that
+// was not found on this system.
+type ToolMissingError struct{ Tool string }
+
+func (e *ToolMissingError) Error() string {
+ return "needs " + e.Tool + ", not installed"
+}
+
+// Tool is one external extractor and the absolute path it was found at, or
+// "" if it was not found.
+type Tool struct{ Name, Path string }
+
+// toolNames lists the external tools Extractor looks up, in the order
+// Tools() reports them.
+var toolNames = []string{"pdftotext", "antiword", "catdoc", "xls2csv", "catppt"}
+
+// markupExt is the set of markup extensions: tags stripped, entities
+// decoded.
+var markupExt = map[string]bool{
+ "html": true, "htm": true, "xhtml": true, "xml": true, "svg": true,
+}
+
+// plainExt is the set of extensions read as plain text without further
+// inspection.
+var plainExt = map[string]bool{
+ "txt": true, "md": true, "log": true, "csv": true, "tsv": true,
+ "json": true, "yaml": true, "yml": true, "toml": true, "ini": true,
+ "conf": true, "cfg": true, "rtf": true, "tex": true, "go": true,
+ "c": true, "h": true, "cpp": true, "hpp": true, "py": true, "sh": true,
+ "js": true, "ts": true, "rs": true, "java": true, "rb": true,
+ "pl": true, "lua": true, "css": true, "sql": true,
+}
+
+// zipExt is the set of Office/OpenDocument/ebook formats: a zip container
+// plus XML inside it (Task 5).
+var zipExt = map[string]bool{
+ "docx": true, "xlsx": true, "pptx": true,
+ "odt": true, "ods": true, "odp": true,
+ "epub": true,
+}
+
+// toolExt maps an extension to the external tool it needs (Task 6). pdf is
+// handled separately, since it has its own fixed command line.
+var toolExt = map[string]string{
+ "doc": "antiword", // falls back to catdoc
+ "xls": "xls2csv",
+ "ppt": "catppt",
+}
+
+// Extractor gets text out of files, using the external tools it found at
+// construction.
+type Extractor struct {
+ tools map[string]string // name -> absolute path, only tools found
+ Timeout time.Duration // per external tool run
+}
+
+// New builds an Extractor, looking up each external tool once in the
+// process's $PATH, with a 30 s per-tool Timeout.
+func New() *Extractor {
+ return newWithPath(os.Getenv("PATH"))
+}
+
+// newWithPath builds an Extractor looking up tools in the given PATH-style
+// list instead of the process environment, so tests control what is found.
+// It does not use exec.LookPath, which reads the process's own PATH; it
+// walks path itself.
+func newWithPath(path string) *Extractor {
+ dirs := filepath.SplitList(path)
+ tools := make(map[string]string, len(toolNames))
+ for _, name := range toolNames {
+ for _, dir := range dirs {
+ if dir == "" {
+ continue
+ }
+ p := filepath.Join(dir, name)
+ info, err := os.Stat(p)
+ if err != nil || !info.Mode().IsRegular() {
+ continue // no such file, or a directory/FIFO/socket/device
+ }
+ if info.Mode()&0o111 == 0 {
+ continue // not executable
+ }
+ tools[name] = p
+ break
+ }
+ }
+ return &Extractor{tools: tools, Timeout: 30 * time.Second}
+}
+
+// Tools reports every external tool Extractor knows about, in a fixed
+// order, with the path it was found at or "" if it was not found.
+func (e *Extractor) Tools() []Tool {
+ out := make([]Tool, len(toolNames))
+ for i, name := range toolNames {
+ out[i] = Tool{Name: name, Path: e.tools[name]}
+ }
+ return out
+}
+
+// Text returns path's raw text content: it does not normalise (callers use
+// norm.Text). size is the file's size, as already known to the caller;
+// maxRead is the configured ceiling, 0 meaning unlimited. Dispatch is on
+// the lower-cased extension; a format extension with no reader yet
+// implemented returns ErrUnsupported.
+func (e *Extractor) Text(ctx context.Context, path string, size, maxRead int64) (string, error) {
+ if maxRead > 0 && size > maxRead {
+ return "", ErrTooLarge
+ }
+
+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
+
+ switch {
+ case ext == "pdf":
+ return e.pdfText(ctx, path, maxRead)
+ case zipExt[ext]:
+ return zipText(ctx, path, ext, maxRead)
+ case toolExt[ext] != "":
+ return e.legacyText(ctx, path, ext, maxRead)
+ case markupExt[ext]:
+ raw, err := readDecoded(path)
+ if err != nil {
+ return "", err
+ }
+ return stripMarkup(raw), nil
+ case plainExt[ext]:
+ return readDecoded(path)
+ default:
+ return sniffText(path)
+ }
+}
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
+// <script> and <style> along with their tags and skipping <!-- ... -->
+// comments outright, then entities are decoded with html.UnescapeString.
+// html.UnescapeString turns &nbsp; into U+00A0 (a non-breaking space, not
+// a plain space); since the source markup used it as ordinary inter-word
+// spacing, it is folded to a regular space here too.
+//
+// A '<' only starts a tag when followed by a letter, '/', '!' or '?' —
+// HTML's own rule for what can open a tag, close tag, comment/doctype, or
+// processing instruction. Anything else (a digit, a space, end of string)
+// is literal text, so "a < b" is not mistaken for markup.
+func stripMarkup(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ i, n := 0, len(s)
+ for i < n {
+ if s[i] != '<' || !startsTag(s, i) {
+ b.WriteByte(s[i])
+ i++
+ continue
+ }
+
+ if strings.HasPrefix(s[i:], "<!--") {
+ end := n
+ if k := strings.Index(s[i+4:], "-->"); k != -1 {
+ end = i + 4 + k + len("-->")
+ }
+ b.WriteByte(' ')
+ i = end
+ continue
+ }
+
+ j := i + 1
+ closing := false
+ if j < n && s[j] == '/' {
+ closing = true
+ j++
+ }
+ nameStart := j
+ for j < n && isTagNameByte(s[j]) {
+ j++
+ }
+ name := strings.ToLower(s[nameStart:j])
+
+ gt := strings.IndexByte(s[j:], '>')
+ if gt == -1 {
+ // D1: an unterminated tag (no closing '>') can no longer be
+ // parsed as markup, but that is no reason to discard the rest
+ // of the file - copy it through as literal text instead of
+ // simply stopping the scan.
+ b.WriteString(s[i:])
+ break
+ }
+ end := j + gt + 1
+
+ if !closing && (name == "script" || name == "style") {
+ if close := indexCloseTag(s[end:], name); close != -1 {
+ end += close
+ if gt2 := strings.IndexByte(s[end:], '>'); gt2 != -1 {
+ end += gt2 + 1
+ } else {
+ end = n
+ }
+ } else {
+ end = n
+ }
+ }
+
+ b.WriteByte(' ')
+ i = end
+ }
+
+ return strings.ReplaceAll(html.UnescapeString(b.String()), string(nbsp), " ")
+}
+
+// startsTag reports whether s[i] == '<' begins a tag-like construct: the
+// next character is a letter, '/', '!' or '?'. A '<' at the very end of s,
+// or followed by anything else (digit, space, punctuation), is literal
+// text instead.
+func startsTag(s string, i int) bool {
+ if i+1 >= len(s) {
+ return false
+ }
+ c := s[i+1]
+ return c == '/' || c == '!' || c == '?' ||
+ (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+}
+
+// nbsp is U+00A0, NO-BREAK SPACE: what html.UnescapeString decodes &nbsp;
+// to, folded to a regular space since the source markup used it as one.
+const nbsp = rune(0xA0)
+
+// isTagNameByte reports whether c can appear in an HTML/XML tag name.
+func isTagNameByte(c byte) bool {
+ return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == ':' || c == '_'
+}
+
+// indexCloseTag returns the index in s of the first ASCII case-insensitive
+// occurrence of "</name", or -1.
+func indexCloseTag(s, name string) int {
+ target := "</" + name
+ tn := len(target)
+ for i := 0; i+tn <= len(s); i++ {
+ if asciiEqualFold(s[i:i+tn], target) {
+ return i
+ }
+ }
+ return -1
+}
+
+// asciiEqualFold reports whether a and b are equal, ASCII letters compared
+// without regard to case.
+func asciiEqualFold(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := 0; i < len(a); i++ {
+ ca, cb := a[i], b[i]
+ if 'A' <= ca && ca <= 'Z' {
+ ca += 'a' - 'A'
+ }
+ if 'A' <= cb && cb <= 'Z' {
+ cb += 'a' - 'A'
+ }
+ if ca != cb {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/extract/plain_test.go b/internal/extract/plain_test.go
new file mode 100644
index 0000000..045ea26
--- /dev/null
+++ b/internal/extract/plain_test.go
@@ -0,0 +1,229 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+// file writes data to name in a temp dir and returns the path.
+func file(t *testing.T, name string, data []byte) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), name)
+ if err := os.WriteFile(p, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func text(t *testing.T, e *Extractor, p string, maxRead int64) (string, error) {
+ t.Helper()
+ fi, err := os.Stat(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return e.Text(context.Background(), p, fi.Size(), maxRead)
+}
+
+func TestPlainEncodings(t *testing.T) {
+ e := newWithPath("")
+ utf16le := []byte{0xFF, 0xFE, 'A', 0, 'c', 0, 'm', 0, 'e', 0}
+ tests := []struct {
+ name string
+ data []byte
+ want string
+ }{
+ {"a.txt", []byte("Faktura acme ltd\n"), "Faktura acme ltd\n"},
+ {"bom.txt", []byte("\xEF\xBB\xBFhello"), "hello"},
+ {"latin1.txt", []byte("Gr\xfc\xdfe"), "Grüße"},
+ {"u16.txt", utf16le, "Acme"},
+ {"notes.md", []byte("# Title\nbody"), "# Title\nbody"},
+ {"empty.txt", nil, ""},
+ {"README", []byte("no extension but text"), "no extension but text"},
+ }
+ for _, tt := range tests {
+ got, err := text(t, e, file(t, tt.name, tt.data), 0)
+ if err != nil || got != tt.want {
+ t.Errorf("%s: got %q, %v; want %q", tt.name, got, err, tt.want)
+ }
+ }
+}
+
+func TestMarkup(t *testing.T) {
+ e := newWithPath("")
+ tests := []struct {
+ name string
+ src string
+ want []string // at least one of these must be a substring
+ wantAny bool // if true, want is an alternative set: any one suffices
+ bad []string // none of these may be a substring
+ }{
+ {
+ name: "tags, script/style dropped, entities decoded",
+ src: "<html><head><style>p{color:red}</style><script>var x='acme'</script></head>" +
+ "<body><p>Faktura&nbsp;VAT &amp; co</p><p>acme&#32;ltd</p></body></html>",
+ want: []string{"Faktura VAT & co", "acme ltd"},
+ bad: []string{"color:red", "var x", "<p>"},
+ },
+ {
+ name: "a lone < followed by a digit or space is literal text",
+ src: "<p>price < 500 zl, done</p>",
+ want: []string{"price < 500 zl, done"},
+ wantAny: true,
+ },
+ {
+ name: "an HTML comment is skipped, not its neighbours",
+ src: "<p>a<!-- hidden -->b</p>",
+ want: []string{"a b", "ab"},
+ wantAny: true,
+ bad: []string{"hidden"},
+ },
+ }
+ for _, tt := range tests {
+ got, err := text(t, e, file(t, "page.html", []byte(tt.src)), 0)
+ if err != nil {
+ t.Fatalf("%s: %v", tt.name, err)
+ }
+ if tt.wantAny {
+ ok := false
+ for _, w := range tt.want {
+ if strings.Contains(got, w) {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ t.Errorf("%s: markup text %q has none of %q", tt.name, got, tt.want)
+ }
+ } else {
+ for _, want := range tt.want {
+ if !strings.Contains(got, want) {
+ t.Errorf("%s: markup text %q lacks %q", tt.name, got, want)
+ }
+ }
+ }
+ for _, bad := range tt.bad {
+ if strings.Contains(got, bad) {
+ t.Errorf("%s: markup text %q still contains %q", tt.name, got, bad)
+ }
+ }
+ }
+}
+
+func TestUnsupportedAndTooLarge(t *testing.T) {
+ e := newWithPath("")
+ png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
+ if _, err := text(t, e, file(t, "image.png", png), 0); !errors.Is(err, ErrUnsupported) {
+ t.Errorf("png: %v, want ErrUnsupported", err)
+ }
+ if _, err := text(t, e, file(t, "blob.bin", []byte{1, 2, 0, 3}), 0); !errors.Is(err, ErrUnsupported) {
+ t.Errorf("binary: %v, want ErrUnsupported", err)
+ }
+ if _, err := text(t, e, file(t, "big.txt", []byte(strings.Repeat("x", 100))), 50); !errors.Is(err, ErrTooLarge) {
+ t.Errorf("big: %v, want ErrTooLarge", err)
+ }
+}
+
+func TestToolMissingError(t *testing.T) {
+ err := &ToolMissingError{Tool: "pdftotext"}
+ if err.Error() != "needs pdftotext, not installed" {
+ t.Fatalf("got %q", err.Error())
+ }
+}
+
+func TestToolsListedInOrder(t *testing.T) {
+ var names []string
+ for _, tl := range newWithPath("").Tools() {
+ names = append(names, tl.Name)
+ if tl.Path != "" {
+ t.Errorf("%s found with an empty PATH", tl.Name)
+ }
+ }
+ if strings.Join(names, " ") != "pdftotext antiword catdoc xls2csv catppt" {
+ t.Fatalf("tools = %v", names)
+ }
+}
+
+// TestSniffWholeFileMustBeValid: the first 8 KiB sniffs as plain ASCII text,
+// but the file goes on to hold an invalid UTF-8 byte and a NUL past that
+// sample — sniffText must reject the whole file, not just decode what the
+// sample alone promised (it must not fall back to Latin-1 the way a known
+// text extension would).
+func TestSniffWholeFileMustBeValid(t *testing.T) {
+ e := newWithPath("")
+ data := append([]byte(strings.Repeat("x", 8192)), 0xFF, 0x00)
+ if _, err := text(t, e, file(t, "blob.data", data), 0); !errors.Is(err, ErrUnsupported) {
+ t.Errorf("got %v, want ErrUnsupported", err)
+ }
+}
+
+// TestSniffLargeBinaryRejected: an unrecognised-extension file whose very
+// first byte is invalid UTF-8 is rejected from the sample alone; this only
+// checks the outcome (ErrUnsupported), not that the rest of the megabyte
+// went unread — that efficiency claim isn't something a black-box test can
+// time reliably.
+func TestSniffLargeBinaryRejected(t *testing.T) {
+ e := newWithPath("")
+ data := make([]byte, 1<<20) // 1 MiB, far past the 8 KiB sniff window
+ data[0] = 0xFF // invalid UTF-8 lead byte, visible in the sample
+ if _, err := text(t, e, file(t, "huge.blob", data), 0); !errors.Is(err, ErrUnsupported) {
+ t.Errorf("got %v, want ErrUnsupported", err)
+ }
+}
+
+// TestToolLookupSkipsNonRegular: a FIFO named like a tool, executable bits
+// and all, must never be picked up — only a regular file counts.
+func TestToolLookupSkipsNonRegular(t *testing.T) {
+ dir := t.TempDir()
+ fifo := filepath.Join(dir, "pdftotext")
+ if err := syscall.Mkfifo(fifo, 0o755); err != nil {
+ t.Skipf("mkfifo not available: %v", err)
+ }
+ for _, tl := range newWithPath(dir).Tools() {
+ if tl.Name == "pdftotext" && tl.Path != "" {
+ t.Errorf("pdftotext resolved to a non-regular file: %s", tl.Path)
+ }
+ }
+}
+
+// TestUnterminatedTagKeepsRemainder: D1. An unterminated ordinary tag (no
+// closing '>') must not discard the rest of the file - only the malformed
+// tag markup itself is unrecoverable; whatever follows it is still real
+// content and must still reach the extracted text.
+func TestUnterminatedTagKeepsRemainder(t *testing.T) {
+ e := newWithPath("")
+ src := "<p>before</p><p unterminated text after"
+ got, err := text(t, e, file(t, "broken.html", []byte(src)), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "before") {
+ t.Errorf("text before the unterminated tag missing: %q", got)
+ }
+ if !strings.Contains(got, "unterminated text after") {
+ t.Errorf("text after the unterminated tag was discarded: %q", got)
+ }
+}
+
+// TestSniffRuneStraddlingSampleBoundary: D2. A multi-byte rune ("ż", two
+// UTF-8 bytes) placed exactly so its lead byte is the sniff sample's last
+// byte and its continuation byte falls just past it must not make an
+// otherwise valid UTF-8 file sniff as unsupported.
+func TestSniffRuneStraddlingSampleBoundary(t *testing.T) {
+ e := newWithPath("")
+ prefix := strings.Repeat("a", sniffSize-1)
+ data := []byte(prefix + "ż" + "bcd")
+ got, err := text(t, e, file(t, "straddle.blob", data), 0)
+ if err != nil {
+ t.Fatalf("valid UTF-8 with a rune straddling the sniff boundary: %v", err)
+ }
+ if want := "żbcd"; !strings.HasSuffix(got, want) {
+ t.Errorf("got tail %q, want it to end in %q", got[len(got)-8:], want)
+ }
+}
diff --git a/internal/extract/tools.go b/internal/extract/tools.go
new file mode 100644
index 0000000..ca6a9b0
--- /dev/null
+++ b/internal/extract/tools.go
@@ -0,0 +1,246 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "time"
+)
+
+// maxToolOutput caps the bytes kept from an external tool's stdout; more
+// than this returns ErrTooLarge. A package var, not a const, so a test can
+// lower it without generating gigabytes of fake output. B1: the directory's
+// max-read may cap a single extraction further still — see budget.
+var maxToolOutput int64 = 64 << 20
+
+// budget returns the smaller of fixed (the package's own default ceiling —
+// maxToolOutput or zipBudget) and maxRead, the directory's configured
+// max-read; maxRead 0 means unlimited, so fixed alone applies. B1: a
+// single extraction's output must never exceed the ceiling the user set,
+// even when that ceiling is below the fixed default.
+func budget(fixed, maxRead int64) int64 {
+ if maxRead > 0 && maxRead < fixed {
+ return maxRead
+ }
+ return fixed
+}
+
+// maxStderr caps the bytes kept from an external tool's stderr — enough
+// for a diagnostic first line. Unlike stdout, crossing this never kills
+// the tool: stderr noise is not grounds to abort an otherwise-working
+// extraction, only grounds to stop remembering more of it.
+const maxStderr = 4 << 10
+
+// maxErrLine caps how much of stderr's first line is folded into the
+// error text run() returns, so a flooding tool cannot make that message
+// itself unbounded.
+const maxErrLine = 200
+
+// boundedWriter keeps at most limit bytes written to it and silently
+// discards the rest, always reporting success to the writer — an
+// io.Writer that returns an error would abort the copy goroutine
+// exec.Cmd runs for Stdout/Stderr, which is not what should happen here:
+// the tool must keep being drained (or be killed outright, via
+// onOverflow) rather than have its pipe start backing up. If onOverflow
+// is set, it fires exactly once, the moment the total ever written first
+// exceeds limit; run() uses it on stdout, and only stdout, to cancel the
+// command immediately rather than let an over-producing tool run until
+// e.Timeout. Write is only ever called by the single copy goroutine
+// exec.Cmd runs per stream, so no locking is needed; run() only reads a
+// boundedWriter's fields after cmd.Run() has returned, which happens
+// strictly after that goroutine has finished (Wait's documented
+// synchronisation), giving the read a safe happens-before.
+type boundedWriter struct {
+ limit int64
+ onOverflow func()
+
+ buf bytes.Buffer
+ total int64
+ overflowed bool
+}
+
+func (w *boundedWriter) Write(p []byte) (int, error) {
+ w.total += int64(len(p))
+ if w.total > w.limit {
+ if !w.overflowed {
+ w.overflowed = true
+ if w.onOverflow != nil {
+ w.onOverflow()
+ }
+ }
+ return len(p), nil
+ }
+ w.buf.Write(p)
+ return len(p), nil
+}
+
+// run executes tool (looked up in e.tools, its absolute path) with args,
+// under a timeout of e.Timeout, and returns its stdout as text, its
+// stdout capped at maxOut bytes (the caller passes budget(maxToolOutput,
+// maxRead), B1). No shell is involved: exec.CommandContext runs the
+// tool's path directly with args passed separately, so nothing in a
+// hostile filename or argument is ever interpreted. The environment is
+// inherited unchanged.
+//
+// os/exec is left to own all the copying — cmd.Stdout and cmd.Stderr are
+// bounded writers, and cmd.Run does the reading — so that cmd.WaitDelay's
+// hang protection actually applies: WaitDelay bounds how long Wait spends
+// on I/O after the process itself has exited (or after ctx is done),
+// forcibly closing the pipes once that grace period elapses. An earlier
+// version of this function read stdout itself, ahead of Wait, which
+// starved WaitDelay of the thing it bounds: once that manual read
+// stopped (at EOF, or at the output cap), Wait was left blocked on
+// whatever was still holding the pipe open — a grandchild the tool
+// backgrounded and left running, or the tool itself blocked writing to a
+// pipe nobody was draining once the cap was hit — with nothing left to
+// force it closed. In both shapes the call could run for the full
+// e.Timeout (or longer) instead of returning promptly.
+//
+// Stdout is capped at maxOut bytes (the caller's budget(maxToolOutput,
+// maxRead), B1): crossing it cancels the command immediately, via the
+// boundedWriter's onOverflow, and run reports ErrTooLarge. Stderr is
+// capped at maxStderr bytes and never
+// cancels anything; only its first line, truncated to maxErrLine bytes,
+// ever reaches an error message, so a tool flooding stderr costs bounded
+// memory and produces a bounded error.
+//
+// Killing the command — by the caller's ctx being cancelled, by
+// e.Timeout expiring, or by the stdout cap being crossed — can leave
+// cmd.Run reporting exec.ErrWaitDelay even though the process's own exit
+// status was clean: SIGKILL forces the pipes closed without giving the
+// child a chance to flush or exit on its own. That alone is not a
+// failure (see the ErrWaitDelay case below); only a genuinely non-zero
+// exit is treated as one.
+//
+// A non-zero exit returns "<tool> failed: <first line of stderr>" (or
+// "<tool> failed: <err>" when stderr was empty), the line capped to
+// maxErrLine bytes. A caller-cancelled ctx returns promptly, with an
+// error wrapping ctx.Err(); e.Timeout expiring on its own returns
+// "<tool> timed out after <Timeout>".
+func (e *Extractor) run(ctx context.Context, maxOut int64, tool string, args ...string) (string, error) {
+ path := e.tools[tool]
+
+ runCtx, cancel := context.WithTimeout(ctx, e.Timeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(runCtx, path, args...)
+ cmd.WaitDelay = time.Second
+
+ stdout := &boundedWriter{limit: maxOut, onOverflow: cancel}
+ stderr := &boundedWriter{limit: maxStderr}
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+
+ err := cmd.Run()
+
+ switch {
+ case stdout.overflowed:
+ // Checked first: killing the tool for overflow also cancels
+ // runCtx, so without this ordering the case below would report
+ // the cancellation as a timeout instead of what it actually was.
+ return "", ErrTooLarge
+ case ctx.Err() != nil:
+ // The caller's own context, not the internal e.Timeout deadline
+ // derived from it — checked before runCtx's, since runCtx
+ // inherits the caller's cancellation too and would otherwise be
+ // indistinguishable from it below.
+ return "", fmt.Errorf("%s: %w", tool, ctx.Err())
+ case runCtx.Err() == context.DeadlineExceeded:
+ return "", fmt.Errorf("%s timed out after %s", tool, e.Timeout)
+ }
+
+ if err != nil {
+ if errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == 0 {
+ return stdout.buf.String(), nil
+ }
+ if line := truncate(firstLine(stderr.buf.Bytes()), maxErrLine); line != "" {
+ return "", fmt.Errorf("%s failed: %s", tool, line)
+ }
+ return "", fmt.Errorf("%s failed: %s", tool, err)
+ }
+ return stdout.buf.String(), nil
+}
+
+// firstLine returns the first non-empty line of b, trimmed of its
+// trailing newline, or "" if b holds nothing but blank lines.
+func firstLine(b []byte) string {
+ for _, line := range bytes.Split(b, []byte("\n")) {
+ if len(bytes.TrimSpace(line)) > 0 {
+ return string(bytes.TrimRight(line, "\r"))
+ }
+ }
+ return ""
+}
+
+// truncate returns s cut to at most n bytes, so text built from
+// untrusted tool output has a hard, predictable bound on its length
+// regardless of what the tool wrote. It may cut a multi-byte UTF-8
+// sequence in two; a trailing partial rune in a diagnostic error message
+// is an acceptable cost for a byte bound that never slips.
+func truncate(s string, n int) string {
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}
+
+// pdfText extracts text from a PDF with pdftotext, its output capped per
+// budget(maxToolOutput, maxRead) (B1).
+func (e *Extractor) pdfText(ctx context.Context, path string, maxRead int64) (string, error) {
+ if e.tools["pdftotext"] == "" {
+ return "", &ToolMissingError{Tool: "pdftotext"}
+ }
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return "", err
+ }
+ return e.run(ctx, budget(maxToolOutput, maxRead), "pdftotext", "-q", "-enc", "UTF-8", abs, "-")
+}
+
+// legacyText extracts text from a legacy binary Office format (doc xls
+// ppt) with the external tool toolExt names, its output capped per
+// budget(maxToolOutput, maxRead) (B1). .doc prefers antiword, falling
+// back to catdoc if antiword is absent or fails.
+func (e *Extractor) legacyText(ctx context.Context, path, ext string, maxRead int64) (string, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return "", err
+ }
+ out := budget(maxToolOutput, maxRead)
+
+ switch ext {
+ case "doc":
+ haveAntiword := e.tools["antiword"] != ""
+ haveCatdoc := e.tools["catdoc"] != ""
+ if !haveAntiword && !haveCatdoc {
+ return "", &ToolMissingError{Tool: "antiword or catdoc"}
+ }
+ if haveAntiword {
+ text, err := e.run(ctx, out, "antiword", "-m", "UTF-8.txt", abs)
+ if err == nil {
+ return text, nil
+ }
+ if !haveCatdoc {
+ return "", err
+ }
+ }
+ return e.run(ctx, out, "catdoc", "-d", "utf-8", abs)
+ case "xls":
+ if e.tools["xls2csv"] == "" {
+ return "", &ToolMissingError{Tool: "xls2csv"}
+ }
+ return e.run(ctx, out, "xls2csv", "-d", "utf-8", abs)
+ case "ppt":
+ if e.tools["catppt"] == "" {
+ return "", &ToolMissingError{Tool: "catppt"}
+ }
+ return e.run(ctx, out, "catppt", "-d", "utf-8", abs)
+ default:
+ return "", errors.New("extract: unreachable: legacyText called with unknown extension " + ext)
+ }
+}
diff --git a/internal/extract/tools_test.go b/internal/extract/tools_test.go
new file mode 100644
index 0000000..4a03f1b
--- /dev/null
+++ b/internal/extract/tools_test.go
@@ -0,0 +1,239 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakeTool writes an executable shell script named name into dir.
+func fakeTool(t *testing.T, dir, name, body string) {
+ t.Helper()
+ script := "#!/bin/sh\n" + body + "\n"
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPdfViaFakeTool(t *testing.T) {
+ bin := t.TempDir()
+ argsFile := filepath.Join(t.TempDir(), "args")
+ fakeTool(t, bin, "pdftotext", `printf '%s\n' "$@" > "`+argsFile+`"; echo "acme ltd invoice"`)
+ e := newWithPath(bin)
+ p := file(t, "-leading-dash.pdf", []byte("%PDF-1.4"))
+ got, err := text(t, e, p, 0)
+ if err != nil || strings.TrimSpace(got) != "acme ltd invoice" {
+ t.Fatalf("got %q, %v", got, err)
+ }
+ args, _ := os.ReadFile(argsFile)
+ want := "-q\n-enc\nUTF-8\n" + p + "\n-\n"
+ if string(args) != want {
+ t.Fatalf("pdftotext args:\n%q\nwant\n%q", args, want)
+ }
+ if !filepath.IsAbs(strings.Split(string(args), "\n")[3]) {
+ t.Fatal("path argument is not absolute")
+ }
+}
+
+func TestLegacyFormats(t *testing.T) {
+ bin := t.TempDir()
+ fakeTool(t, bin, "catdoc", `echo "from catdoc"`)
+ fakeTool(t, bin, "xls2csv", `echo "from xls2csv"`)
+ fakeTool(t, bin, "catppt", `echo "from catppt"`)
+ e := newWithPath(bin) // no antiword: .doc falls back to catdoc
+ for ext, want := range map[string]string{"doc": "from catdoc", "xls": "from xls2csv", "ppt": "from catppt"} {
+ got, err := text(t, e, file(t, "f."+ext, []byte("x")), 0)
+ if err != nil || strings.TrimSpace(got) != want {
+ t.Errorf("%s: got %q, %v", ext, got, err)
+ }
+ }
+ fakeTool(t, bin, "antiword", `echo "from antiword"`)
+ e = newWithPath(bin)
+ if got, _ := text(t, e, file(t, "g.doc", []byte("x")), 0); strings.TrimSpace(got) != "from antiword" {
+ t.Errorf("antiword not preferred: %q", got)
+ }
+ fakeTool(t, bin, "antiword", `echo "antiword broke" >&2; exit 1`)
+ e = newWithPath(bin)
+ if got, _ := text(t, e, file(t, "h.doc", []byte("x")), 0); strings.TrimSpace(got) != "from catdoc" {
+ t.Errorf("no fallback to catdoc after antiword failed: %q", got)
+ }
+}
+
+func TestToolErrors(t *testing.T) {
+ e := newWithPath(t.TempDir())
+ var tm *ToolMissingError
+ if _, err := text(t, e, file(t, "a.pdf", []byte("x")), 0); !errors.As(err, &tm) || tm.Error() != "needs pdftotext, not installed" {
+ t.Errorf("missing pdftotext: %v", err)
+ }
+ if _, err := text(t, e, file(t, "a.doc", []byte("x")), 0); err == nil || err.Error() != "needs antiword or catdoc, not installed" {
+ t.Errorf("missing doc tools: %v", err)
+ }
+ bin := t.TempDir()
+ fakeTool(t, bin, "pdftotext", `echo "Syntax Error: broken xref" >&2; exit 3`)
+ e = newWithPath(bin)
+ if _, err := text(t, e, file(t, "b.pdf", []byte("x")), 0); err == nil || err.Error() != "pdftotext failed: Syntax Error: broken xref" {
+ t.Errorf("failing tool: %v", err)
+ }
+ fakeTool(t, bin, "pdftotext", `sleep 5`)
+ e = newWithPath(bin)
+ e.Timeout = 200 * time.Millisecond
+ start := time.Now()
+ _, err := text(t, e, file(t, "c.pdf", []byte("x")), 0)
+ if err == nil || !strings.Contains(err.Error(), "pdftotext timed out after 200ms") {
+ t.Errorf("slow tool: %v", err)
+ }
+ if time.Since(start) > 3*time.Second {
+ t.Errorf("timeout took %v", time.Since(start))
+ }
+}
+
+// minimalPDF returns a valid one-page PDF showing text in Helvetica.
+func minimalPDF(text string) []byte {
+ var b bytes.Buffer
+ var offsets []int
+ obj := func(s string) { offsets = append(offsets, b.Len()); b.WriteString(s) }
+ b.WriteString("%PDF-1.4\n")
+ obj("1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n")
+ obj("2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n")
+ obj("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n")
+ stream := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
+ obj(fmt.Sprintf("4 0 obj << /Length %d >> stream\n%s\nendstream endobj\n", len(stream), stream))
+ obj("5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n")
+ xref := b.Len()
+ fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
+ for _, o := range offsets {
+ fmt.Fprintf(&b, "%010d 00000 n \n", o)
+ }
+ fmt.Fprintf(&b, "trailer << /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
+ return b.Bytes()
+}
+
+func TestRealPdftotext(t *testing.T) {
+ if _, err := exec.LookPath("pdftotext"); err != nil {
+ t.Skip("pdftotext not installed")
+ }
+ e := New()
+ got, err := text(t, e, file(t, "hello.pdf", minimalPDF("Hello acme ltd")), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "Hello acme ltd") {
+ t.Fatalf("pdftotext gave %q", got)
+ }
+}
+
+// TestOrphanChildDoesNotHangRun: a tool that exits itself but leaves a
+// backgrounded grandchild holding its stdout pipe open must not make
+// run() wait for that grandchild. Only os/exec itself, copying into
+// cmd.Stdout and bounding the post-exit wait via WaitDelay, can end this
+// promptly; run() manually draining a StdoutPipe before calling Wait
+// starves WaitDelay of the thing it bounds, since by the time Wait runs
+// there is nothing left for it to forcibly cut off.
+func TestOrphanChildDoesNotHangRun(t *testing.T) {
+ bin := t.TempDir()
+ fakeTool(t, bin, "pdftotext", "sleep 5 & echo x")
+ e := newWithPath(bin)
+ e.Timeout = 30 * time.Second
+
+ start := time.Now()
+ got, err := e.run(context.Background(), maxToolOutput, "pdftotext")
+ if err != nil {
+ t.Fatalf("orphan: %v", err)
+ }
+ if strings.TrimSpace(got) != "x" {
+ t.Errorf("orphan: got %q, want %q", got, "x")
+ }
+ if d := time.Since(start); d > 3*time.Second {
+ t.Errorf("orphan: took %v, want well under e.Timeout (30s) and under 3s", d)
+ }
+}
+
+// TestStdoutOverflowKillsToolPromptly: a tool that keeps writing past
+// maxToolOutput must be killed the moment the cap is crossed, not left
+// running until e.Timeout expires — once run() stops draining its pipe,
+// an unkilled tool blocks on its own write() and never exits on its own.
+func TestStdoutOverflowKillsToolPromptly(t *testing.T) {
+ old := maxToolOutput
+ maxToolOutput = 1 << 20
+ defer func() { maxToolOutput = old }()
+
+ bin := t.TempDir()
+ fakeTool(t, bin, "pdftotext", "yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ e := newWithPath(bin)
+ e.Timeout = 30 * time.Second
+
+ start := time.Now()
+ _, err := e.run(context.Background(), maxToolOutput, "pdftotext")
+ if !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("overflow: got %v, want ErrTooLarge", err)
+ }
+ if d := time.Since(start); d > 5*time.Second {
+ t.Errorf("overflow: took %v, want under 5s (well under e.Timeout=30s)", d)
+ }
+}
+
+// TestCallerCancelReturnsPromptly: cancelling the ctx passed to run()
+// (distinct from e.Timeout's own internal deadline, which is untouched
+// here) must stop the tool and return quickly, with an error wrapping
+// the caller's own context.Canceled — not silently absorbed into a
+// generic "<tool> failed: ..." string, and not held open until
+// e.Timeout.
+func TestCallerCancelReturnsPromptly(t *testing.T) {
+ bin := t.TempDir()
+ fakeTool(t, bin, "pdftotext", "sleep 10")
+ e := newWithPath(bin)
+ e.Timeout = 30 * time.Second
+
+ ctx, cancel := context.WithCancel(context.Background())
+ time.AfterFunc(100*time.Millisecond, cancel)
+
+ start := time.Now()
+ _, err := e.run(ctx, maxToolOutput, "pdftotext")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("caller cancel: got %v, want an error wrapping context.Canceled", err)
+ }
+ if d := time.Since(start); d > 3*time.Second {
+ t.Errorf("caller cancel: took %v, want under 3s", d)
+ }
+}
+
+// TestStderrFloodBounded: a tool that floods stderr must not blow up the
+// size of the error message run() produces — only a bounded prefix of
+// its first line may ever reach the returned error text, and capturing
+// it at all must not cost unbounded memory.
+func TestStderrFloodBounded(t *testing.T) {
+ bin := t.TempDir()
+ fakeTool(t, bin, "pdftotext", `head -c 10000000 /dev/zero | tr '\0' x >&2; exit 1`)
+ e := newWithPath(bin)
+
+ _, err := e.run(context.Background(), maxToolOutput, "pdftotext")
+ if err == nil {
+ t.Fatal("stderr flood: want an error")
+ }
+ if max := len("pdftotext failed: ") + 200; len(err.Error()) > max {
+ t.Errorf("stderr flood: message is %d bytes, want <=%d", len(err.Error()), max)
+ }
+}
+
+// TestMaxReadCapsToolOutput: B1. A directory's max-read, when smaller than
+// the fixed maxToolOutput default, caps a single tool's output on its
+// own — proven here with maxToolOutput left at its default, so only
+// budget(maxToolOutput, maxRead) picking the smaller maxRead explains the
+// result.
+func TestMaxReadCapsToolOutput(t *testing.T) {
+ bin := t.TempDir()
+ fakeTool(t, bin, "pdftotext", `yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | head -c 200000`)
+ e := newWithPath(bin)
+ if _, err := text(t, e, file(t, "small.pdf", []byte("%PDF-1.4")), 1024); !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("got %v, want ErrTooLarge (max-read 1024 should have capped a 200000-byte tool output)", err)
+ }
+}
diff --git a/internal/extract/zipxml.go b/internal/extract/zipxml.go
new file mode 100644
index 0000000..1b3ba82
--- /dev/null
+++ b/internal/extract/zipxml.go
@@ -0,0 +1,224 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "archive/zip"
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+)
+
+// zipBudget caps the total uncompressed bytes read from one archive's
+// matched entries, guarding against a zip bomb; a test lowers it. B1: the
+// directory's max-read may cap a single archive further still — see
+// budget (tools.go).
+var zipBudget int64 = 64 << 20
+
+// zipPatterns maps a zip-based format's extension to the path.Match
+// patterns (tried in matchEntry) of the entries that carry its text.
+// Invoices often carry the tax number only in a header or footer, which is
+// why docx's header*/footer* entries are included; ODF keeps headers and
+// footers in styles.xml, not content.xml, so both are read.
+var zipPatterns = map[string][]string{
+ "docx": {"word/document.xml", "word/header*.xml", "word/footer*.xml", "word/footnotes.xml"},
+ "xlsx": {"xl/sharedStrings.xml", "xl/worksheets/sheet*.xml"},
+ "pptx": {"ppt/slides/slide*.xml"},
+ "odt": {"content.xml", "styles.xml"},
+ "ods": {"content.xml", "styles.xml"},
+ "odp": {"content.xml", "styles.xml"},
+ "epub": {"*.xhtml", "*.html", "*.htm"},
+}
+
+// zipText extracts text from a zip-based document (docx xlsx pptx odt ods
+// odp epub): it opens the archive and, for each entry matching the
+// format's zipPatterns in archive order, decodes its XML character data
+// into the result with xmlText, separating entries with a newline. A
+// corrupt archive returns the zip package's own error, not
+// ErrUnsupported: the file claims a format it does not have, which the
+// user should see. Reading stops with ErrTooLarge immediately once the
+// entries read from the archive exceed budget(zipBudget, maxRead)
+// uncompressed bytes in total (B1: maxRead, the directory's configured
+// ceiling, may cap this lower than the fixed zipBudget). ctx is checked
+// between entries so a cancelled extraction stops
+// promptly.
+//
+// Any other per-entry failure — the entry won't open, or its XML is
+// malformed — is lenient rather than fatal: whatever text that entry had
+// already yielded (xmlText writes as it walks, so a syntax error partway
+// through still leaves the text read up to that point) is kept, and the
+// archive keeps going to its remaining entries, since one bad part (a
+// corrupt header, say) should not blank out a document's otherwise
+// readable body. The first such error is remembered, wrapped as "<entry
+// name>: <err>", and returned only if no matched entry ever wrote any
+// character data at all — an error report is more useful than silent
+// empty text when nothing could be read. "Wrote any character data" is
+// tracked per entry (via the builder's length just before and after that
+// entry's own xmlText call, not the whole archive's final length): the
+// newline zipText adds to separate a successful entry from the next one
+// would otherwise make an entry that parsed cleanly but held no text of
+// its own (an empty element, say) look like it had produced something,
+// which could then mask a later entry's genuine failure.
+func zipText(ctx context.Context, path, ext string, maxRead int64) (string, error) {
+ zr, err := zip.OpenReader(path)
+ if err != nil {
+ return "", err
+ }
+ defer zr.Close()
+
+ patterns := zipPatterns[ext]
+ var b strings.Builder
+ remaining := budget(zipBudget, maxRead)
+ var firstErr error
+ wroteText := false
+ for _, f := range zr.File {
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ if !matchEntry(patterns, f.Name) {
+ continue
+ }
+ before := b.Len()
+ err := readZipEntry(f, &remaining, &b)
+ // Measured before the separator below is written, so a
+ // separator alone (an entry that parsed but held no character
+ // data) never counts as "wrote text" — only xmlText's own
+ // writes do, whether or not this entry went on to error.
+ if b.Len() > before {
+ wroteText = true
+ }
+ if err != nil {
+ if errors.Is(err, ErrTooLarge) {
+ return "", ErrTooLarge
+ }
+ if firstErr == nil {
+ firstErr = fmt.Errorf("%s: %w", f.Name, err)
+ }
+ continue
+ }
+ b.WriteByte('\n')
+ }
+ if !wroteText && firstErr != nil {
+ return "", firstErr
+ }
+ return b.String(), nil
+}
+
+// matchEntry reports whether name is one of the entries a format reads:
+// each pattern is tried first against the full entry name — which is what
+// the docx/xlsx/pptx/odt directory-qualified patterns need — and, failing
+// that, against name's base name. The base-name fallback is what lets
+// epub's bare "*.xhtml"/"*.html"/"*.htm" find chapters nested at any depth
+// inside the archive; applied to every format, it also means a nested
+// part sharing a matched base name is picked up deliberately, not by
+// accident — e.g. an ODF embedded object's own "Object 1/content.xml"
+// matches odt/ods/odp's bare "content.xml" pattern alongside the
+// document's own content.xml, because an embedded chart's or formula's
+// text is text the document shows its reader.
+func matchEntry(patterns []string, name string) bool {
+ base := path.Base(name)
+ for _, p := range patterns {
+ if ok, _ := path.Match(p, name); ok {
+ return true
+ }
+ if ok, _ := path.Match(p, base); ok {
+ return true
+ }
+ }
+ return false
+}
+
+// readZipEntry opens one matched zip entry, decodes its text into b
+// through a budgetedReader sharing remaining across the whole archive, and
+// reports ErrTooLarge if that budget was exceeded — checked on the reader
+// itself after xmlText returns, since the XML decoder may not pass the
+// reader's own error through unchanged (a truncated entry can look like a
+// cleanly finished document).
+func readZipEntry(f *zip.File, remaining *int64, b *strings.Builder) error {
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ defer rc.Close()
+
+ br := &budgetedReader{r: rc, remaining: remaining}
+ err = xmlText(br, b)
+ if br.exceeded {
+ return ErrTooLarge
+ }
+ return err
+}
+
+// budgetedReader wraps a zip entry's reader, decrementing remaining — a
+// counter shared across every entry read from one archive — as bytes are
+// read. Once remaining is exhausted it stops reading and reports io.EOF
+// instead, recording that in exceeded so the caller can tell a genuine
+// end of document from a budget cutoff.
+type budgetedReader struct {
+ r io.Reader
+ remaining *int64
+ exceeded bool
+}
+
+func (br *budgetedReader) Read(p []byte) (int, error) {
+ if *br.remaining <= 0 {
+ br.exceeded = true
+ return 0, io.EOF
+ }
+ if int64(len(p)) > *br.remaining {
+ p = p[:*br.remaining]
+ }
+ n, err := br.r.Read(p)
+ *br.remaining -= int64(n)
+ return n, err
+}
+
+// xmlText appends the character data of one XML entry to b, with the
+// separators described above. inSharedCell tracks xlsx <c t="s">.
+func xmlText(r io.Reader, b *strings.Builder) error {
+ dec := xml.NewDecoder(r)
+ dec.Strict = false
+ dec.Entity = xml.HTMLEntity
+ sharedCell, inV := false, false
+ for {
+ tok, err := dec.Token()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ switch t := tok.(type) {
+ case xml.StartElement:
+ switch t.Name.Local {
+ case "s", "tab", "br", "line-break", "cr":
+ b.WriteByte(' ')
+ case "c":
+ sharedCell = false
+ for _, a := range t.Attr {
+ if a.Name.Local == "t" && a.Value == "s" {
+ sharedCell = true
+ }
+ }
+ case "v":
+ inV = true
+ }
+ case xml.EndElement:
+ switch t.Name.Local {
+ case "p", "h", "tc", "tr", "td", "th", "li", "si", "c", "row", "div", "title":
+ b.WriteByte('\n')
+ case "v":
+ inV = false
+ }
+ case xml.CharData:
+ if inV && sharedCell {
+ continue
+ }
+ b.Write(t)
+ }
+ }
+}
diff --git a/internal/extract/zipxml_test.go b/internal/extract/zipxml_test.go
new file mode 100644
index 0000000..14ead80
--- /dev/null
+++ b/internal/extract/zipxml_test.go
@@ -0,0 +1,205 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package extract
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "strings"
+ "testing"
+)
+
+// zipFile builds an archive from name -> content pairs and writes it to disk.
+func zipFile(t *testing.T, name string, entries map[string]string) string {
+ t.Helper()
+ var buf bytes.Buffer
+ w := zip.NewWriter(&buf)
+ for n, c := range entries {
+ f, err := w.Create(n)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.Write([]byte(c)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return file(t, name, buf.Bytes())
+}
+
+func TestZipFormats(t *testing.T) {
+ e := newWithPath("")
+ tests := []struct {
+ name string
+ entries map[string]string
+ want []string
+ }{
+ {"inv.docx", map[string]string{
+ "word/document.xml": `<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>Ac</w:t></w:r><w:r><w:t>me Ltd</w:t></w:r></w:p><w:p><w:r><w:t>Faktura</w:t><w:tab/><w:t>VAT</w:t></w:r></w:p></w:body></w:document>`,
+ "word/footer1.xml": `<w:ftr xmlns:w="w"><w:p><w:r><w:t>NIP 0000000000</w:t></w:r></w:p></w:ftr>`,
+ "word/styles.xml": `<w:styles xmlns:w="w"><w:t>NOT-INCLUDED</w:t></w:styles>`,
+ }, []string{"Acme Ltd", "Faktura VAT", "NIP 0000000000"}},
+ {"sheet.xlsx", map[string]string{
+ "xl/sharedStrings.xml": `<sst><si><t>Invoice</t></si><si><t>acme ltd</t></si></sst>`,
+ "xl/worksheets/sheet1.xml": `<worksheet><sheetData><row><c t="s"><v>0</v></c><c><v>1234567890</v></c><c t="inlineStr"><is><t>inline text</t></is></c></row></sheetData></worksheet>`,
+ }, []string{"Invoice", "acme ltd", "1234567890", "inline text"}},
+ {"deck.pptx", map[string]string{
+ "ppt/slides/slide1.xml": `<p:sld xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>Quarterly report</a:t></a:r></a:p></p:sld>`,
+ }, []string{"Quarterly report"}},
+ {"letter.odt", map[string]string{
+ "content.xml": `<office:document-content xmlns:office="o" xmlns:text="t"><text:p>Faktura<text:s/>VAT</text:p></office:document-content>`,
+ "styles.xml": `<office:document-styles xmlns:office="o" xmlns:text="t"><text:p>header acme</text:p></office:document-styles>`,
+ }, []string{"Faktura VAT", "header acme"}},
+ {"book.epub", map[string]string{
+ "OEBPS/ch1.xhtml": `<html xmlns="h"><body><p>Chapter one&amp;two</p></body></html>`,
+ "mimetype": `application/epub+zip`,
+ }, []string{"Chapter one&two"}},
+ }
+ for _, tt := range tests {
+ got, err := text(t, e, zipFile(t, tt.name, tt.entries), 0)
+ if err != nil {
+ t.Errorf("%s: %v", tt.name, err)
+ continue
+ }
+ for _, w := range tt.want {
+ if !strings.Contains(got, w) {
+ t.Errorf("%s: text %q lacks %q", tt.name, got, w)
+ }
+ }
+ if strings.Contains(got, "NOT-INCLUDED") {
+ t.Errorf("%s: read an entry it should skip: %q", tt.name, got)
+ }
+ }
+}
+
+func TestSharedStringIndexNotText(t *testing.T) {
+ e := newWithPath("")
+ got, err := text(t, e, zipFile(t, "s.xlsx", map[string]string{
+ "xl/sharedStrings.xml": `<sst><si><t>alpha</t></si></sst>`,
+ "xl/worksheets/sheet1.xml": `<worksheet><sheetData><row><c t="s"><v>987654</v></c></row></sheetData></worksheet>`,
+ }), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(got, "987654") {
+ t.Fatalf("shared-string index leaked into text: %q", got)
+ }
+}
+
+func TestZipCorruptAndBudget(t *testing.T) {
+ e := newWithPath("")
+ if _, err := text(t, e, file(t, "broken.docx", []byte("not a zip at all")), 0); err == nil || errors.Is(err, ErrUnsupported) {
+ t.Errorf("corrupt docx: %v, want a zip error", err)
+ }
+ old := zipBudget
+ zipBudget = 1 << 10
+ defer func() { zipBudget = old }()
+ big := `<w:document xmlns:w="w"><w:p><w:t>` + strings.Repeat("x", 4096) + `</w:t></w:p></w:document>`
+ if _, err := text(t, e, zipFile(t, "huge.docx", map[string]string{"word/document.xml": big}), 0); !errors.Is(err, ErrTooLarge) {
+ t.Errorf("over budget: %v, want ErrTooLarge", err)
+ }
+}
+
+// TestOdfEmbeddedObjectIncluded: an ODF embedded object (a chart, a
+// formula) keeps its own content.xml inside a subdirectory such as
+// "Object 1/"; matchEntry's base-name fallback picks it up alongside the
+// document's own content.xml, deliberately — that embedded text is text
+// the document shows its reader.
+func TestOdfEmbeddedObjectIncluded(t *testing.T) {
+ e := newWithPath("")
+ got, err := text(t, e, zipFile(t, "embed.odt", map[string]string{
+ "content.xml": `<office:document-content xmlns:office="o" xmlns:text="t"><text:p>cover page</text:p></office:document-content>`,
+ "Object 1/content.xml": `<office:document-content xmlns:office="o" xmlns:text="t"><text:p>embedded chart acme</text:p></office:document-content>`,
+ }), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "embedded chart acme") {
+ t.Errorf("embedded object text missing: %q", got)
+ }
+}
+
+// TestZipLenientOnMalformedEntry: a malformed entry must not blank out
+// text already read from a good entry in the same archive, and the text
+// the malformed entry itself yielded before its own error is kept too.
+// The footer's mismatched end tag (</w:xyz> where </w:ftr> was open)
+// genuinely fails to parse under Strict=false — unlike a merely missing
+// end tag, which the decoder synthesises and swallows — but only after
+// "broken" and the newline for </w:p> have already been written; the
+// XML decoder itself confirms this token by token (see fix round 2's
+// report for the trace): CharData "broken", EndElement p, then the
+// error "unexpected end element </xyz>".
+func TestZipLenientOnMalformedEntry(t *testing.T) {
+ e := newWithPath("")
+ got, err := text(t, e, zipFile(t, "partial.docx", map[string]string{
+ "word/document.xml": `<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>good body text</w:t></w:r></w:p></w:body></w:document>`,
+ "word/footer1.xml": `<w:ftr xmlns:w="w"><w:p><w:r><w:t>broken</w:t></w:r></w:p></w:xyz>`,
+ }), 0)
+ if err != nil {
+ t.Fatalf("good entry alongside a malformed one: %v", err)
+ }
+ if !strings.Contains(got, "good body text") {
+ t.Errorf("text from the good entry was discarded: %q", got)
+ }
+ if !strings.Contains(got, "broken") {
+ t.Errorf("text the malformed entry yielded before its own error was discarded: %q", got)
+ }
+}
+
+// TestZipMalformedOnlyEntryErrors: when the only matched entry is
+// malformed, no text survives to return, so the error is reported instead
+// — named after the entry it came from. The malformed attribute syntax
+// breaks the decode before any character data is ever emitted, so there
+// is nothing for the lenient path (TestZipLenientOnMalformedEntry) to
+// keep.
+func TestZipMalformedOnlyEntryErrors(t *testing.T) {
+ e := newWithPath("")
+ _, err := text(t, e, zipFile(t, "bad.docx", map[string]string{
+ "word/document.xml": `<w:document><w:body attr="unterminated><w:p><w:t>oops</w:t></w:p></w:body></w:document>`,
+ }), 0)
+ if err == nil {
+ t.Fatal("malformed only entry: want an error, got nil")
+ }
+ if !strings.Contains(err.Error(), "word/document.xml") {
+ t.Errorf("error %q does not name the entry", err.Error())
+ }
+}
+
+// TestZipNoTextAndFailingSiblingErrors: a well-formed entry that simply
+// has no character data (an empty <w:body/>) must not count as "text was
+// found" and mask a failing sibling's error — the separator newline
+// zipText appends after every successful entry means b.Len() alone
+// cannot answer "did anything write text"; only word/footer1.xml's own
+// contribution (zero, since it fails while still inside its opening tag,
+// before any token is emitted) may be counted, and it contributed
+// nothing either, so the archive as a whole produced no text and the
+// wrapped error must surface.
+func TestZipNoTextAndFailingSiblingErrors(t *testing.T) {
+ e := newWithPath("")
+ _, err := text(t, e, zipFile(t, "empty.docx", map[string]string{
+ "word/document.xml": `<w:document xmlns:w="w"><w:body/></w:document>`,
+ "word/footer1.xml": `<w:ftr xmlns:w="w" a="unterminated><w:p/></w:ftr>`,
+ }), 0)
+ if err == nil {
+ t.Fatal("textless entry plus a failing sibling: want an error, got nil")
+ }
+ if !strings.Contains(err.Error(), "word/footer1.xml") {
+ t.Errorf("error %q does not name the failing entry", err.Error())
+ }
+}
+
+// TestMaxReadCapsZipOutput: B1. A directory's max-read, when smaller than
+// the fixed zipBudget default, caps a single archive's extracted text on
+// its own — zipBudget is left at its default, so only budget(zipBudget,
+// maxRead) picking the smaller maxRead explains the result.
+func TestMaxReadCapsZipOutput(t *testing.T) {
+ e := newWithPath("")
+ big := `<w:document xmlns:w="w"><w:p><w:t>` + strings.Repeat("x", 4096) + `</w:t></w:p></w:document>`
+ p := zipFile(t, "huge.docx", map[string]string{"word/document.xml": big})
+ if _, err := text(t, e, p, 1024); !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("got %v, want ErrTooLarge (max-read 1024 should have capped a 4096-byte entry)", err)
+ }
+}