aboutsummaryrefslogtreecommitdiff
path: root/internal/extract/tools_test.go
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/tools_test.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/extract/tools_test.go')
-rw-r--r--internal/extract/tools_test.go239
1 files changed, 239 insertions, 0 deletions
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)
+ }
+}