diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
| commit | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch) | |
| tree | ecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/extract | |
| parent | 42b02c47be9b285099203e44a2570636d4ca6f03 (diff) | |
| download | krino-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.go | 154 | ||||
| -rw-r--r-- | internal/extract/plain.go | 282 | ||||
| -rw-r--r-- | internal/extract/plain_test.go | 229 | ||||
| -rw-r--r-- | internal/extract/tools.go | 246 | ||||
| -rw-r--r-- | internal/extract/tools_test.go | 239 | ||||
| -rw-r--r-- | internal/extract/zipxml.go | 224 | ||||
| -rw-r--r-- | internal/extract/zipxml_test.go | 205 |
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 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 +// 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 VAT & co</p><p>acme 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&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) + } +} |
