Files
kyleandornith-1.5-35b-a3b f57aaef59e pdftext: extract PDF text in a re-exec'd child (sift pdftext) with a deadline
The parser is pure Go but a goroutine cannot be killed, so extraction runs in
a child process: the binary re-executed as `sift pdftext`, reading the PDF on
stdin and writing JSON Lines on stdout, killed at a deadline by the parent.

pdftext.go holds the types, the five errors, Extract (reader.Open with
ErrWrongPassword -> ErrEncrypted, per-page recover, trailer /Info /Title, and
rule-3 UTF-16BE/Latin-1 text decode) and ChildMain (soft 768 MiB memory limit,
-no env read, no network, exit codes 0/3/4/1). outline.go walks /Outlines ->
/First -> /Next with /First children at level+1, resolving /Dest and /GoTo /A
dests to 1-based pages, bounded at 10,000 items and never visiting an object
twice. runner.go is the Runner: env-isolated child (nil env is an empty
environment), 60 s deadline, 64 MiB output cap, 200 MiB stdin cap, stderr
capped at 4 KiB, exit-code to error mapping, and Doc parsing with ErrNoText.
cmd/sift/main.go adds the unlisted `pdftext` case. tools.go is removed; the
go-pdfkit modules stay vendored.

Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
2026-09-25 22:58:05 -07:00

253 lines
8.4 KiB
Go

package pdftext_test
// P5a task 02: Extract (the child's work), ChildMain (the `sift pdftext` entry point) and Runner
// (the parent: exec, deadline, kill, parse).
//
// The Runner tests run the test binary itself as the child: TestMain turns into the child when
// SIFT_PDFTEXT_CHILD=1 is set, exactly as `sift pdftext` would. The two other variables are test
// hooks that live only here: SIFT_PDFTEXT_SLEEP delays the child (for the deadline test) and
// SIFT_PDFTEXT_PIDFILE records its pid (to prove it is gone afterwards).
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"git.wntrmute.dev/kyle/sift/internal/pdftest"
"git.wntrmute.dev/kyle/sift/internal/pdftext"
)
func TestMain(m *testing.M) {
if os.Getenv("SIFT_PDFTEXT_CHILD") == "1" {
if p := os.Getenv("SIFT_PDFTEXT_PIDFILE"); p != "" {
_ = os.WriteFile(p, []byte(strconv.Itoa(os.Getpid())), 0o600)
}
if d, err := time.ParseDuration(os.Getenv("SIFT_PDFTEXT_SLEEP")); err == nil {
time.Sleep(d)
}
os.Exit(pdftext.ChildMain(os.Args[1:], os.Stdin, os.Stdout))
}
os.Exit(m.Run())
}
func three() []byte {
return pdftest.Build(pdftest.Doc{
Title: "Three Pages",
Pages: []string{
"Chapter one begins here.\nSecond line (with parens).",
"The quick brown fox is on page two.",
pdftest.Lorem(3, 5),
},
Outline: []pdftest.Outline{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "3 Filler", Page: 3, Level: 1}},
})
}
type line struct {
Meta *pdftext.Meta `json:"meta"`
Page int `json:"page"`
Text *string `json:"text"`
Error string `json:"error"`
}
func lines(t *testing.T, out []byte) []line {
t.Helper()
var ls []line
sc := bufio.NewScanner(bytes.NewReader(out))
sc.Buffer(make([]byte, 1<<20), 64<<20)
for sc.Scan() {
var l line
if err := json.Unmarshal(sc.Bytes(), &l); err != nil {
t.Fatalf("output line is not JSON: %q (%v)", sc.Text(), err)
}
ls = append(ls, l)
}
return ls
}
func TestExtractWritesMetaThenPages(t *testing.T) {
var out bytes.Buffer
if err := pdftext.Extract(three(), 2000, &out); err != nil {
t.Fatal(err)
}
ls := lines(t, out.Bytes())
if len(ls) != 4 || ls[0].Meta == nil {
t.Fatalf("got %d lines, first meta=%v; want a meta line then 3 page lines:\n%s", len(ls), ls[0].Meta, out.String())
}
m := ls[0].Meta
if m.Pages != 3 || m.Title != "Three Pages" {
t.Errorf("meta = %+v, want 3 pages titled Three Pages", *m)
}
want := []pdftext.OutlineItem{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "3 Filler", Page: 3, Level: 1}}
if len(m.Outline) != 3 {
t.Fatalf("outline = %+v, want %+v", m.Outline, want)
}
for i := range want {
if m.Outline[i] != want[i] {
t.Errorf("outline[%d] = %+v, want %+v", i, m.Outline[i], want[i])
}
}
for i, l := range ls[1:] {
if l.Page != i+1 || l.Text == nil || l.Error != "" {
t.Errorf("line %d = %+v, want page %d with text", i+1, l, i+1)
}
}
if !strings.Contains(*ls[1].Text, "Chapter one begins here.") || !strings.Contains(*ls[2].Text, "quick brown fox") || !strings.Contains(*ls[3].Text, "p3 l5") {
t.Errorf("page texts wrong: %q / %q / %q", *ls[1].Text, *ls[2].Text, *ls[3].Text)
}
}
func TestExtractPageCap(t *testing.T) {
var out bytes.Buffer
if err := pdftext.Extract(three(), 2, &out); err != nil {
t.Fatal(err)
}
ls := lines(t, out.Bytes())
if len(ls) != 3 || ls[0].Meta.Pages != 3 || ls[2].Page != 2 {
t.Errorf("maxPages 2: %d lines, meta %+v; want meta (pages 3) and pages 1-2", len(ls), ls[0].Meta)
}
}
func TestExtractNoOutline(t *testing.T) {
var out bytes.Buffer
if err := pdftext.Extract(pdftest.Build(pdftest.Doc{Pages: []string{"a", "b"}}), 10, &out); err != nil {
t.Fatal(err)
}
ls := lines(t, out.Bytes())
if ls[0].Meta.Outline == nil || len(ls[0].Meta.Outline) != 0 {
t.Errorf("outline = %#v, want an empty non-nil slice (JSON [])", ls[0].Meta.Outline)
}
if !strings.Contains(out.String(), `"outline":[]`) {
t.Errorf("meta line must carry \"outline\":[]: %s", out.String())
}
}
func TestExtractErrors(t *testing.T) {
enc, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
if err != nil {
t.Fatal(err)
}
if err := pdftext.Extract(enc, 10, &bytes.Buffer{}); !errors.Is(err, pdftext.ErrEncrypted) {
t.Errorf("user-password pdf: %v, want ErrEncrypted", err)
}
owner, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-owner.pdf"))
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := pdftext.Extract(owner, 10, &out); err != nil || !strings.Contains(out.String(), "quick brown fox") {
t.Errorf("owner-password-only pdf: err %v; want its text", err)
}
if err := pdftext.Extract([]byte("not a pdf at all"), 10, &bytes.Buffer{}); !errors.Is(err, pdftext.ErrUnreadable) {
t.Errorf("garbage: %v, want ErrUnreadable", err)
}
// A truncated file either yields what it can or ErrUnreadable; it never panics.
b := three()
_ = pdftext.Extract(b[:len(b)*6/10], 10, &bytes.Buffer{})
}
func TestChildMainExitCodes(t *testing.T) {
enc, _ := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
for _, tc := range []struct {
name string
in []byte
code int
}{
{"ok", three(), 0},
{"encrypted", enc, 3},
{"unreadable", []byte("nope"), 4},
} {
var out bytes.Buffer
if code := pdftext.ChildMain([]string{"-max-pages", "5"}, bytes.NewReader(tc.in), &out); code != tc.code {
t.Errorf("%s: exit %d, want %d", tc.name, code, tc.code)
}
}
}
func runner(env ...string) *pdftext.Runner {
return &pdftext.Runner{Exe: os.Args[0], Env: append([]string{"SIFT_PDFTEXT_CHILD=1"}, env...), Deadline: 20 * time.Second}
}
func TestRunnerRun(t *testing.T) {
doc, err := runner().Run(context.Background(), three())
if err != nil {
t.Fatal(err)
}
if doc.Pages != 3 || doc.Title != "Three Pages" || len(doc.Outline) != 3 || len(doc.Text) != 3 {
t.Fatalf("doc = %+v", doc)
}
if !strings.Contains(doc.Text[1], "quick brown fox") {
t.Errorf("page 2 = %q", doc.Text[1])
}
}
func TestRunnerErrors(t *testing.T) {
enc, _ := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
if _, err := runner().Run(context.Background(), enc); !errors.Is(err, pdftext.ErrEncrypted) {
t.Errorf("encrypted: %v, want ErrEncrypted", err)
}
if _, err := runner().Run(context.Background(), []byte("nope")); !errors.Is(err, pdftext.ErrUnreadable) {
t.Errorf("garbage: %v, want ErrUnreadable", err)
}
scanned := pdftest.Build(pdftest.Doc{Pages: []string{"", "", ""}, Scanned: []int{1, 2, 3}})
if _, err := runner().Run(context.Background(), scanned); !errors.Is(err, pdftext.ErrNoText) {
t.Errorf("scanned: %v, want ErrNoText", err)
}
r := runner()
r.MaxText = 100 // bytes of child output
if _, err := r.Run(context.Background(), three()); !errors.Is(err, pdftext.ErrTooMuchText) {
t.Errorf("output over MaxText: %v, want ErrTooMuchText", err)
}
}
// The child is killed at the deadline and reaped: no process is left behind.
func TestRunnerDeadlineKillsTheChild(t *testing.T) {
pidfile := filepath.Join(t.TempDir(), "pid")
r := runner("SIFT_PDFTEXT_SLEEP=30s", "SIFT_PDFTEXT_PIDFILE="+pidfile)
r.Deadline = 300 * time.Millisecond
start := time.Now()
_, err := r.Run(context.Background(), three())
if !errors.Is(err, pdftext.ErrTimeout) {
t.Fatalf("err %v, want ErrTimeout", err)
}
if d := time.Since(start); d > 3*time.Second {
t.Errorf("Run returned after %v; the 300 ms deadline was not enforced", d)
}
b, err := os.ReadFile(pidfile)
if err != nil {
t.Fatalf("child never wrote its pid: %v", err)
}
pid, _ := strconv.Atoi(string(b))
if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
t.Errorf("child %d still exists after Run returned (kill -0: %v)", pid, err)
}
}
// The caller's context cancels the child too.
func TestRunnerContextCancel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
_, err := runner("SIFT_PDFTEXT_SLEEP=30s").Run(ctx, three())
if err == nil {
t.Fatal("cancelled run succeeded")
}
}
// Runner passes only Env to the child: nothing of the parent's environment leaks in.
func TestRunnerEnvIsExplicit(t *testing.T) {
t.Setenv("SIFT_PDFTEXT_SLEEP", "30s") // in the parent only
r := runner()
r.Deadline = 5 * time.Second
if _, err := r.Run(context.Background(), three()); err != nil {
t.Errorf("parent env leaked into the child (it slept): %v", err)
}
}