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>
253 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|