P5a plan: PDF documents — seven Ornith tasks with acceptance tests first
docs/plans/p5a: 01 kind+fetch limits, 02 pdftext child + runner, 03 dcache PDF tables, 04 pdfpool, 05 pdfdoc render (map/pages/grep), 06 api, 07 digest citations, metrics, CLI, skill, unit, README. Given tests compile against a panic-only skeleton and fail there for the intended reasons; PDFs for tests are built by internal/pdftest (given), plus two qpdf-encrypted fixtures. Owner changes for the run: vendor go-pdfkit/extract v0.3.0 + reader v0.6.0 (pinned via tools.go until task 02); AGENTS.md, tools/run-plan.sh (enforces a Co-Authored-By trailer for the model), docs/implementer-log.md, `make gate`; gofmt (whitespace) on clean.go and guard; TestUnsupportedType uses a zip now that PDFs are supported; requestDeadline 90→210 s and writeTimeout 120→240 s for the PDF path's worst case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
package api
|
||||
|
||||
// P5a task 06: PDFs through /v1/fetch and /v2/scrape — extraction in the child, the pool, the
|
||||
// document cache, the map, pages, grep, and the errors.
|
||||
//
|
||||
// The child is the test binary itself (TestMain below), as in internal/pdftext's tests.
|
||||
// SIFT_PDFTEXT_RUNLOG makes every child start append a line to a file, so a test can count
|
||||
// extractions; SIFT_PDFTEXT_SLEEP slows a child down. Both hooks live only in this file.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/auth"
|
||||
"git.wntrmute.dev/kyle/sift/internal/dcache"
|
||||
"git.wntrmute.dev/kyle/sift/internal/digest"
|
||||
"git.wntrmute.dev/kyle/sift/internal/fetch"
|
||||
"git.wntrmute.dev/kyle/sift/internal/guard"
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
||||
"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_RUNLOG"); p != "" {
|
||||
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString("run\n")
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
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 threePDF() []byte {
|
||||
return pdftest.Build(pdftest.Doc{
|
||||
Title: "Three Pages",
|
||||
Pages: []string{"Chapter one begins here.", "The quick brown fox is on page two.", "Last page."},
|
||||
Outline: []pdftest.Outline{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}},
|
||||
})
|
||||
}
|
||||
|
||||
func longPDF(n int) []byte {
|
||||
var pages []string
|
||||
for p := 1; p <= n; p++ {
|
||||
pages = append(pages, pdftest.Lorem(p, 40))
|
||||
}
|
||||
return pdftest.Build(pdftest.Doc{Title: "Long Thesis", Pages: pages, Outline: []pdftest.Outline{{Title: "1 Introduction", Page: 1, Level: 1}, {Title: "2 Method", Page: n / 2, Level: 1}}})
|
||||
}
|
||||
|
||||
// origin serves one PDF and counts GETs. It honours If-None-Match against its current ETag.
|
||||
type origin struct {
|
||||
mu sync.Mutex
|
||||
ct string
|
||||
body []byte
|
||||
etag string
|
||||
gets atomic.Int32
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newOrigin(t *testing.T, ct string, body []byte) *origin {
|
||||
o := &origin{ct: ct, body: body, etag: `"v1"`}
|
||||
o.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
o.gets.Add(1)
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if r.Header.Get("If-None-Match") == o.etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", o.etag)
|
||||
if o.ct != "" {
|
||||
w.Header().Set("Content-Type", o.ct)
|
||||
}
|
||||
_, _ = w.Write(o.body)
|
||||
}))
|
||||
t.Cleanup(o.srv.Close)
|
||||
return o
|
||||
}
|
||||
|
||||
func (o *origin) replace(body []byte, etag string) {
|
||||
o.mu.Lock()
|
||||
o.body, o.etag = body, etag
|
||||
o.mu.Unlock()
|
||||
}
|
||||
|
||||
type pdfRig struct {
|
||||
s *Server
|
||||
fleet string // bearer key of caller "test" (hermes-fleet)
|
||||
other string // bearer key of caller "other" (hermes-fleet)
|
||||
gs string // bearer key of restricted caller "straylight-gs"
|
||||
runlog string
|
||||
logs *recHandler
|
||||
}
|
||||
|
||||
func newPDFRig(t *testing.T, childEnv ...string) *pdfRig {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
keys := filepath.Join(dir, "keys.toml")
|
||||
key := func(name, ws string) string {
|
||||
out := filepath.Join(dir, name)
|
||||
if err := auth.Add(keys, name, ws, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
r := &pdfRig{fleet: key("test", "hermes-fleet"), other: key("other", "hermes-fleet"), gs: key("straylight-gs", "guardian-star"), runlog: filepath.Join(dir, "runs"), logs: &recHandler{}}
|
||||
store, err := auth.Load(keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &guard.Guard{AllowPrefixes: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}
|
||||
s := New("test", g, &fetch.Fetcher{Guard: g, UA: "sift-test"}, store, slog.New(r.logs))
|
||||
s.PermitRestricted = []string{"straylight-gs"}
|
||||
db, err := dcache.Open(filepath.Join(dir, "cache.db"), 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
s.Digest = &digest.Engine{Cache: db} // the cache lives on the digest engine; no model is configured
|
||||
s.PDFRunner = &pdftext.Runner{Exe: os.Args[0], Env: append([]string{"SIFT_PDFTEXT_CHILD=1", "SIFT_PDFTEXT_RUNLOG=" + r.runlog}, childEnv...), Deadline: 20 * time.Second}
|
||||
r.s = s
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *pdfRig) runs() int {
|
||||
b, _ := os.ReadFile(r.runlog)
|
||||
return strings.Count(string(b), "run\n")
|
||||
}
|
||||
|
||||
func (r *pdfRig) fetch(t *testing.T, key string, body map[string]any) (int, map[string]any, http.Header) {
|
||||
t.Helper()
|
||||
rec := postJSON(t, r.s, "/v1/fetch", key, body)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out, rec.Header()
|
||||
}
|
||||
|
||||
func pdfInfo(t *testing.T, out map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
p, ok := out["pdf"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope has no pdf object: %v", out)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestPDFCleanFetch(t *testing.T) {
|
||||
for _, ct := range []string{"application/pdf", "application/octet-stream", ""} {
|
||||
r := newPDFRig(t)
|
||||
o := newOrigin(t, ct, threePDF())
|
||||
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||
if code != 200 {
|
||||
t.Fatalf("%q: %d %v", ct, code, out)
|
||||
}
|
||||
content, _ := out["content"].(string)
|
||||
if !strings.Contains(content, "--- page 2 ---") || !strings.Contains(content, "quick brown fox") || out["mode"] != "clean" {
|
||||
t.Errorf("%q: content %q mode %v", ct, content, out["mode"])
|
||||
}
|
||||
p := pdfInfo(t, out)
|
||||
if p["pages"] != 3.0 || p["title"] != "Three Pages" || p["map"] != false || p["first"] != 1.0 || p["last"] != 3.0 {
|
||||
t.Errorf("%q: pdf = %v", ct, p)
|
||||
}
|
||||
if ol, _ := p["outline"].([]any); len(ol) != 2 {
|
||||
t.Errorf("%q: outline = %v", ct, p["outline"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFMap(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
r.s.OutCap = 20000
|
||||
o := newOrigin(t, "application/pdf", longPDF(40)) // ~40 × 2,700 characters
|
||||
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||
if code != 200 {
|
||||
t.Fatalf("%d %v", code, out)
|
||||
}
|
||||
content, _ := out["content"].(string)
|
||||
p := pdfInfo(t, out)
|
||||
if p["map"] != true || !strings.Contains(content, "## Outline") || !strings.Contains(content, "- 2 Method (p. 20)") || len([]rune(content)) > 20000 {
|
||||
t.Errorf("map: pdf %v, %d chars:\n%.400s", p, len([]rune(content)), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFPagesAndGrep(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
o := newOrigin(t, "application/pdf", threePDF())
|
||||
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL, "pages": "2-3"})
|
||||
content, _ := out["content"].(string)
|
||||
if code != 200 || strings.Contains(content, "--- page 1 ---") || !strings.Contains(content, "--- page 3 ---") {
|
||||
t.Errorf("pages 2-3: %d %q", code, content)
|
||||
}
|
||||
if p := pdfInfo(t, out); p["first"] != 2.0 || p["last"] != 3.0 {
|
||||
t.Errorf("pages 2-3: pdf %v", p)
|
||||
}
|
||||
for _, bad := range []map[string]any{{"pages": "0-1"}, {"pages": "9"}, {"grep": strings.Repeat("a", 201)}, {"grep": "(unclosed"}} {
|
||||
bad["url"] = o.srv.URL
|
||||
if code, out, _ := r.fetch(t, r.fleet, bad); code != 400 || out["success"] != false {
|
||||
t.Errorf("%v: %d %v, want 400", bad, code, out)
|
||||
}
|
||||
}
|
||||
code, out, _ = r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL, "grep": "quick brown"})
|
||||
content, _ = out["content"].(string)
|
||||
if code != 200 || !strings.Contains(content, "p. 2: The quick brown fox is on page two.") || pdfInfo(t, out)["grep_hits"] != 1.0 {
|
||||
t.Errorf("grep: %d %q %v", code, content, out["pdf"])
|
||||
}
|
||||
// pages and grep on a non-PDF are refused rather than ignored.
|
||||
html := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = io.WriteString(w, "<html><body><p>hi</p></body></html>")
|
||||
}))
|
||||
defer html.Close()
|
||||
if code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": html.URL, "pages": "1"}); code != 400 {
|
||||
t.Errorf("pages on html: %d %v, want 400", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFCache(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
o := newOrigin(t, "application/pdf", threePDF())
|
||||
get := func(extra map[string]any) string {
|
||||
body := map[string]any{"url": o.srv.URL}
|
||||
for k, v := range extra {
|
||||
body[k] = v
|
||||
}
|
||||
code, out, _ := r.fetch(t, r.fleet, body)
|
||||
if code != 200 {
|
||||
t.Fatalf("%v: %d %v", extra, code, out)
|
||||
}
|
||||
c, _ := out["content"].(string)
|
||||
return c
|
||||
}
|
||||
get(nil)
|
||||
if o.gets.Load() != 1 || r.runs() != 1 {
|
||||
t.Fatalf("first fetch: %d GETs, %d extractions; want 1 and 1", o.gets.Load(), r.runs())
|
||||
}
|
||||
if c := get(map[string]any{"pages": "2"}); !strings.Contains(c, "quick brown fox") {
|
||||
t.Errorf("cached pages=2: %q", c)
|
||||
}
|
||||
if o.gets.Load() != 1 || r.runs() != 1 {
|
||||
t.Errorf("fresh cache hit: %d GETs, %d extractions; want still 1 and 1", o.gets.Load(), r.runs())
|
||||
}
|
||||
// Stale: a conditional GET, answered 304, re-uses the text.
|
||||
r.s.PDFTTL = time.Nanosecond
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
get(map[string]any{"grep": "Chapter"})
|
||||
if o.gets.Load() != 2 || r.runs() != 1 {
|
||||
t.Errorf("after 304: %d GETs, %d extractions; want 2 and 1", o.gets.Load(), r.runs())
|
||||
}
|
||||
// The document changed: a full GET and a new extraction.
|
||||
o.replace(pdftest.Build(pdftest.Doc{Pages: []string{"Brand new text."}}), `"v2"`)
|
||||
if c := get(nil); !strings.Contains(c, "Brand new text.") {
|
||||
t.Errorf("changed document: %q", c)
|
||||
}
|
||||
if o.gets.Load() != 3 || r.runs() != 2 {
|
||||
t.Errorf("after change: %d GETs, %d extractions; want 3 and 2", o.gets.Load(), r.runs())
|
||||
}
|
||||
if DefaultPDFTTL != 24*time.Hour {
|
||||
t.Errorf("DefaultPDFTTL = %v, want 24h", DefaultPDFTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFErrors(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
enc, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body []byte
|
||||
want string
|
||||
hint string
|
||||
}{
|
||||
{"scanned", pdftest.Build(pdftest.Doc{Pages: []string{"", ""}, Scanned: []int{1, 2}}), "no text layer", "ocr"},
|
||||
{"encrypted", enc, "encrypted", ""},
|
||||
{"garbage", []byte("%PDF-1.4\nthis is not really a pdf"), "unreadable", ""},
|
||||
} {
|
||||
o := newOrigin(t, "application/pdf", tc.body)
|
||||
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||
msg, _ := out["error"].(string)
|
||||
if code != 422 || !strings.Contains(msg, tc.want) || (tc.hint != "" && out["hint"] != tc.hint) {
|
||||
t.Errorf("%s: %d %v; want 422 %q hint %q", tc.name, code, out, tc.want, tc.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFTimeout(t *testing.T) {
|
||||
r := newPDFRig(t, "SIFT_PDFTEXT_SLEEP=10s")
|
||||
r.s.PDFRunner.Deadline = 300 * time.Millisecond
|
||||
o := newOrigin(t, "application/pdf", threePDF())
|
||||
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL})
|
||||
if msg, _ := out["error"].(string); code != 422 || !strings.Contains(msg, "timed out") {
|
||||
t.Errorf("slow child: %d %v; want 422 … timed out", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFBusy(t *testing.T) {
|
||||
r := newPDFRig(t, "SIFT_PDFTEXT_SLEEP=1500ms")
|
||||
r.s.PDFPool = pdfpool.New(1, 0, 100*time.Millisecond)
|
||||
a := newOrigin(t, "application/pdf", threePDF())
|
||||
b := newOrigin(t, "application/pdf", longPDF(2))
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
code, _, _ := r.fetch(t, r.fleet, map[string]any{"url": a.srv.URL})
|
||||
done <- code
|
||||
}()
|
||||
time.Sleep(400 * time.Millisecond) // the first extraction holds the only slot
|
||||
code, out, hdr := r.fetch(t, r.other, map[string]any{"url": b.srv.URL})
|
||||
if code != 503 || hdr.Get("Retry-After") != "10" || !strings.Contains(fmt.Sprint(out["error"]), "busy") {
|
||||
t.Errorf("second caller with the pool full: %d %v Retry-After %q; want 503, busy, 10", code, out, hdr.Get("Retry-After"))
|
||||
}
|
||||
code, out, _ = r.fetch(t, r.fleet, map[string]any{"url": b.srv.URL})
|
||||
if code != 429 {
|
||||
t.Errorf("same caller, second document while its first is running: %d %v; want 429", code, out)
|
||||
}
|
||||
if c := <-done; c != 200 {
|
||||
t.Errorf("first fetch: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFRestrictedCaller(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
o := newOrigin(t, "application/pdf", threePDF())
|
||||
if code, out, _ := r.fetch(t, r.gs, map[string]any{"url": o.srv.URL}); code != 200 {
|
||||
t.Fatalf("restricted fetch: %d %v", code, out)
|
||||
}
|
||||
r.logs.mu.Lock()
|
||||
for _, rec := range r.logs.recs {
|
||||
var b strings.Builder
|
||||
b.WriteString(rec.Message)
|
||||
rec.Attrs(func(a slog.Attr) bool { fmt.Fprintf(&b, " %s=%v", a.Key, a.Value); return true })
|
||||
line := b.String()
|
||||
if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "Three Pages") || strings.Contains(line, "quick brown") {
|
||||
t.Errorf("a restricted caller's PDF fetch logged %q", line)
|
||||
}
|
||||
}
|
||||
r.logs.mu.Unlock()
|
||||
// The fleet does not see the restricted caller's cached text: it extracts its own copy.
|
||||
if code, _, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL}); code != 200 || r.runs() != 2 {
|
||||
t.Errorf("fleet after restricted: %d, %d extractions; want 200 and 2 (separate cache partitions)", code, r.runs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFScrape(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
o := newOrigin(t, "application/pdf", threePDF())
|
||||
rec := postJSON(t, r.s, "/v2/scrape", r.fleet, map[string]any{"url": o.srv.URL, "formats": []string{"markdown"}})
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Markdown string `json:"markdown"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || rec.Code != 200 || !out.Success {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(out.Data.Markdown, "--- page 2 ---") || out.Data.Metadata["numPages"] != 3.0 || out.Data.Metadata["title"] != "Three Pages" {
|
||||
t.Errorf("scrape: markdown %q metadata %v", out.Data.Markdown, out.Data.Metadata)
|
||||
}
|
||||
if ct, _ := out.Data.Metadata["contentType"].(string); !strings.HasPrefix(ct, "application/pdf") {
|
||||
t.Errorf("contentType = %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// A question on a document longer than PDFMaxQuestionChunks is refused with a pointer to grep
|
||||
// and pages, before any model call.
|
||||
func TestPDFQuestionTooLong(t *testing.T) {
|
||||
r := newPDFRig(t)
|
||||
r.s.PDFMaxQuestionChunks = 2
|
||||
o := newOrigin(t, "application/pdf", longPDF(12))
|
||||
code, out, _ := r.fetch(t, r.fleet, map[string]any{"url": o.srv.URL, "question": "what is the method?"})
|
||||
if msg, _ := out["error"].(string); code != 422 || !strings.Contains(msg, "too long") || !strings.Contains(msg, "grep") {
|
||||
t.Errorf("long question: %d %v; want 422 … too long … grep", code, out)
|
||||
}
|
||||
if DefaultPDFMaxQuestionChunks != 200 {
|
||||
t.Errorf("DefaultPDFMaxQuestionChunks = %d, want 200", DefaultPDFMaxQuestionChunks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package clean_test
|
||||
|
||||
// P5a task 01: Kind decides which cleaner a fetched body gets, now including "pdf".
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/clean"
|
||||
)
|
||||
|
||||
func TestKind(t *testing.T) {
|
||||
pdf := []byte("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj << >> endobj\n")
|
||||
junkThenPDF := append([]byte(strings.Repeat(" ", 900)), pdf...) // the spec allows leading junk
|
||||
lateMagic := append([]byte(strings.Repeat("x", 2000)), pdf...) // past the first 1024 bytes
|
||||
binary := []byte{0x50, 0x4b, 0x03, 0x04, 0x00, 0x00} // a zip
|
||||
for _, tc := range []struct {
|
||||
name, ct string
|
||||
body []byte
|
||||
want string
|
||||
}{
|
||||
{"pdf by type", "application/pdf", pdf, "pdf"},
|
||||
{"pdf by type with params", "Application/PDF; qs=0.9", pdf, "pdf"},
|
||||
{"x-pdf", "application/x-pdf", pdf, "pdf"},
|
||||
{"pdf type, body not checked", "application/pdf", []byte("oops"), "pdf"},
|
||||
{"octet-stream sniffed as pdf", "application/octet-stream", pdf, "pdf"},
|
||||
{"binary/octet-stream sniffed as pdf", "binary/octet-stream", pdf, "pdf"},
|
||||
{"no type sniffed as pdf", "", pdf, "pdf"},
|
||||
{"leading junk within 1024 bytes", "application/octet-stream", junkThenPDF, "pdf"},
|
||||
{"magic too late", "application/octet-stream", lateMagic, "other"},
|
||||
{"octet-stream that is not a pdf", "application/octet-stream", binary, "other"},
|
||||
{"html stays html even if it mentions %PDF-", "text/html; charset=utf-8", []byte("<html>%PDF-1.4</html>"), "html"},
|
||||
{"json", "application/json", []byte(`{"a":1}`), "json"},
|
||||
{"problem+json", "application/problem+json", []byte(`{}`), "json"},
|
||||
{"text", "text/plain", []byte("hello"), "text"},
|
||||
{"no type, html", "", []byte("<!doctype html><html></html>"), "html"},
|
||||
{"no type, json", "", []byte(`[1,2]`), "json"},
|
||||
{"no type, text", "", []byte("just words"), "text"},
|
||||
{"zip", "application/zip", binary, "other"},
|
||||
{"image", "image/png", []byte{0x89, 'P', 'N', 'G'}, "other"},
|
||||
} {
|
||||
if got := clean.Kind(tc.ct, tc.body); got != tc.want {
|
||||
t.Errorf("%s: Kind(%q, …) = %q, want %q", tc.name, tc.ct, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MediaKind keeps answering for callers that have no body: the pdf types are "pdf" there too.
|
||||
func TestMediaKindKnowsPDF(t *testing.T) {
|
||||
for ct, want := range map[string]string{"application/pdf": "pdf", "application/x-pdf": "pdf", "text/html": "html", "": "sniff"} {
|
||||
if got := clean.MediaKind(ct); got != want {
|
||||
t.Errorf("MediaKind(%q) = %q, want %q", ct, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package dcache_test
|
||||
|
||||
// P5a task 03: the PDF document cache. One entry per (URL, caller class): how to revalidate it,
|
||||
// its metadata (opaque JSON here) and the text of every extracted page.
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/dcache"
|
||||
)
|
||||
|
||||
func openDB(t *testing.T, maxBytes int64) *dcache.DB {
|
||||
t.Helper()
|
||||
db, err := dcache.Open(filepath.Join(t.TempDir(), "c.db"), maxBytes, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func entry(pages ...string) dcache.PDFEntry {
|
||||
return dcache.PDFEntry{
|
||||
ContentHash: "sha256:abc",
|
||||
ETag: `"v1"`,
|
||||
LastModified: "Fri, 25 Sep 2026 12:00:00 GMT",
|
||||
FetchedAt: time.Date(2026, 9, 25, 20, 0, 0, 0, time.UTC),
|
||||
Meta: []byte(`{"pages":3,"title":"T","outline":[]}`),
|
||||
Pages: pages,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFRoundTrip(t *testing.T) {
|
||||
db := openDB(t, 0)
|
||||
k := dcache.PDFKey("https://example.org/a.pdf", dcache.ClassFleet)
|
||||
if _, ok := db.GetPDF(k); ok {
|
||||
t.Fatal("hit on an empty cache")
|
||||
}
|
||||
want := entry("page one", "page two", "", "page four") // an empty page keeps its place
|
||||
db.PutPDF(k, want)
|
||||
got, ok := db.GetPDF(k)
|
||||
if !ok {
|
||||
t.Fatal("miss after put")
|
||||
}
|
||||
if got.ContentHash != want.ContentHash || got.ETag != want.ETag || got.LastModified != want.LastModified ||
|
||||
!got.FetchedAt.Equal(want.FetchedAt) || string(got.Meta) != string(want.Meta) {
|
||||
t.Errorf("got %+v, want %+v", got, want)
|
||||
}
|
||||
if len(got.Pages) != 4 || got.Pages[0] != "page one" || got.Pages[2] != "" || got.Pages[3] != "page four" {
|
||||
t.Errorf("pages = %q", got.Pages)
|
||||
}
|
||||
// A second put replaces the whole entry, pages included.
|
||||
db.PutPDF(k, entry("only"))
|
||||
got, _ = db.GetPDF(k)
|
||||
if len(got.Pages) != 1 || got.Pages[0] != "only" {
|
||||
t.Errorf("after replace, pages = %q; want [only]", got.Pages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyPartitions(t *testing.T) {
|
||||
db := openDB(t, 0)
|
||||
u := "https://example.org/a.pdf"
|
||||
fleet, restricted := dcache.PDFKey(u, dcache.ClassFleet), dcache.PDFKey(u, dcache.ClassRestricted)
|
||||
if fleet == restricted {
|
||||
t.Fatal("the same key for both caller classes")
|
||||
}
|
||||
db.PutPDF(restricted, entry("secret page"))
|
||||
if _, ok := db.GetPDF(fleet); ok {
|
||||
t.Error("a restricted caller's document is visible to the fleet")
|
||||
}
|
||||
if dcache.PDFKey(u, dcache.ClassFleet) != fleet || dcache.PDFKey("https://example.org/b.pdf", dcache.ClassFleet) == fleet {
|
||||
t.Error("PDFKey must be deterministic and differ per URL")
|
||||
}
|
||||
if strings.Contains(fleet, "example.org") {
|
||||
t.Error("the key must not carry the URL in the clear (it is hashed like the other keys)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFTouch(t *testing.T) {
|
||||
db := openDB(t, 0)
|
||||
k := dcache.PDFKey("https://example.org/a.pdf", dcache.ClassFleet)
|
||||
db.PutPDF(k, entry("x"))
|
||||
later := time.Date(2026, 9, 26, 20, 0, 0, 0, time.UTC)
|
||||
db.TouchPDF(k, later) // after a 304: the text is unchanged, only its freshness moves
|
||||
got, _ := db.GetPDF(k)
|
||||
if !got.FetchedAt.Equal(later) || len(got.Pages) != 1 {
|
||||
t.Errorf("after touch: %+v", got)
|
||||
}
|
||||
db.TouchPDF(dcache.PDFKey("https://nope", dcache.ClassFleet), later) // a miss is a no-op
|
||||
}
|
||||
|
||||
// Documents count toward maxBytes and are evicted whole, oldest use first.
|
||||
func TestPDFEviction(t *testing.T) {
|
||||
db := openDB(t, 300<<10) // 300 KiB
|
||||
big := strings.Repeat("z", 50<<10)
|
||||
var keys []string
|
||||
for i := 0; i < 10; i++ { // 10 × 2 pages × 50 KiB = 1 MiB of text
|
||||
k := dcache.PDFKey("https://example.org/"+string(rune('a'+i))+".pdf", dcache.ClassFleet)
|
||||
keys = append(keys, k)
|
||||
db.PutPDF(k, entry(big, big))
|
||||
}
|
||||
if db.Size() > 300<<10 {
|
||||
t.Errorf("size %d after puts, want ≤ maxBytes %d", db.Size(), 300<<10)
|
||||
}
|
||||
if _, ok := db.GetPDF(keys[9]); !ok {
|
||||
t.Error("the newest document was evicted")
|
||||
}
|
||||
if _, ok := db.GetPDF(keys[0]); ok {
|
||||
t.Error("the oldest document survived; eviction did not reach PDF entries")
|
||||
}
|
||||
for _, k := range keys {
|
||||
if e, ok := db.GetPDF(k); ok && len(e.Pages) != 2 {
|
||||
t.Errorf("a partly evicted document: %d pages", len(e.Pages))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFNilDB(t *testing.T) {
|
||||
var db *dcache.DB
|
||||
db.PutPDF("k", entry("x"))
|
||||
db.TouchPDF("k", time.Now())
|
||||
if _, ok := db.GetPDF("k"); ok {
|
||||
t.Error("nil DB returned a hit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package digest
|
||||
|
||||
// P5a task 07: a digest of a PDF cites pages. The page markers come from internal/pdfdoc; the
|
||||
// prompt tells the model to use them, and the prompt version moves so cached digests made with
|
||||
// the old prompt are not served.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPromptCitesPages(t *testing.T) {
|
||||
if !strings.Contains(systemPrompt, "--- page N ---") || !strings.Contains(systemPrompt, "(p. N)") {
|
||||
t.Errorf("system prompt must tell the model to cite pages from --- page N --- markers:\n%s", systemPrompt)
|
||||
}
|
||||
if PromptVersion == "2" {
|
||||
t.Error("PromptVersion must change with the prompt (was \"2\")")
|
||||
}
|
||||
}
|
||||
|
||||
// Page markers survive chunking whole: none is cut across two chunks. (This holds for today's
|
||||
// Split, whose paragraph boundaries fall around each marker; the test keeps it that way.)
|
||||
func TestSplitKeepsPageMarkers(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for p := 1; p <= 40; p++ {
|
||||
b.WriteString("--- page " + strconv.Itoa(p) + " ---\n\n")
|
||||
b.WriteString(strings.Repeat("word ", 600))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
marker := regexp.MustCompile(`--- page \d+ ---`)
|
||||
n := 0
|
||||
for _, c := range Split(b.String(), ChunkTokens) {
|
||||
n += len(marker.FindAllString(c, -1))
|
||||
}
|
||||
if n != 40 {
|
||||
t.Errorf("%d whole page markers across the chunks, want 40", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package fetch_test
|
||||
|
||||
// P5a task 01: a PDF response gets a larger body cap and a longer deadline than a web page, and
|
||||
// FetchIf revalidates a cached document with a conditional GET.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/fetch"
|
||||
"git.wntrmute.dev/kyle/sift/internal/guard"
|
||||
)
|
||||
|
||||
func loopbackFetcher() *fetch.Fetcher {
|
||||
g := &guard.Guard{AllowPrefixes: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}
|
||||
return &fetch.Fetcher{Guard: g, UA: "sift-test"}
|
||||
}
|
||||
|
||||
func body(prefix string, n int) []byte {
|
||||
b := bytes.Repeat([]byte("x"), n)
|
||||
copy(b, prefix)
|
||||
return b
|
||||
}
|
||||
|
||||
func serve(t *testing.T, ct string, b []byte) string {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts.URL
|
||||
}
|
||||
|
||||
func status(err error) int {
|
||||
var fe *fetch.Error
|
||||
if errors.As(err, &fe) {
|
||||
return fe.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestPDFBodyCap(t *testing.T) {
|
||||
six := 6 << 20 // over DefaultMaxBody (5 MiB), under DefaultPDFMaxBody (100 MiB)
|
||||
for _, tc := range []struct {
|
||||
name, ct string
|
||||
b []byte
|
||||
want int // 0 = ok
|
||||
}{
|
||||
{"pdf by type", "application/pdf", body("%PDF-1.7\n", six), 0},
|
||||
{"pdf by sniffing octet-stream", "application/octet-stream", body("%PDF-1.7\n", six), 0},
|
||||
{"pdf with no type", "", body("%PDF-1.7\n", six), 0},
|
||||
{"html keeps the 5 MiB cap", "text/html", body("<html>", six), 413},
|
||||
{"octet-stream that is not a pdf keeps the cap", "application/octet-stream", body("PK\x03\x04", six), 413},
|
||||
} {
|
||||
f := loopbackFetcher()
|
||||
res, err := f.Fetch(context.Background(), serve(t, tc.ct, tc.b))
|
||||
if tc.want == 0 {
|
||||
if err != nil || res == nil || len(res.Body) != six {
|
||||
t.Errorf("%s: err %v, body %d bytes; want the whole %d", tc.name, err, lenBody(res), six)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status(err) != tc.want {
|
||||
t.Errorf("%s: err %v (status %d), want %d", tc.name, err, status(err), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lenBody(r *fetch.Resource) int {
|
||||
if r == nil {
|
||||
return -1
|
||||
}
|
||||
return len(r.Body)
|
||||
}
|
||||
|
||||
func TestPDFMaxBodyIsConfigurable(t *testing.T) {
|
||||
f := loopbackFetcher()
|
||||
f.PDFMaxBody = 1 << 20
|
||||
_, err := f.Fetch(context.Background(), serve(t, "application/pdf", body("%PDF-1.7\n", 2<<20)))
|
||||
if status(err) != 413 || !strings.Contains(err.Error(), "too large") {
|
||||
t.Errorf("2 MiB pdf over a 1 MiB PDFMaxBody: %v, want 413 too large", err)
|
||||
}
|
||||
if fetch.DefaultPDFMaxBody != 100<<20 || fetch.DefaultPDFTimeout != 120*time.Second {
|
||||
t.Errorf("defaults = %d, %v; want 100 MiB, 120s", fetch.DefaultPDFMaxBody, fetch.DefaultPDFTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// slow sends the headers, then the body in pieces over about 600 ms.
|
||||
func slow(t *testing.T, ct string, first string) string {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.WriteHeader(200)
|
||||
fl, _ := w.(http.Flusher)
|
||||
_, _ = w.Write([]byte(first))
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = w.Write(bytes.Repeat([]byte("y"), 1024))
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts.URL
|
||||
}
|
||||
|
||||
// The web-page deadline (Timeout) still applies to HTML; a PDF gets PDFTimeout.
|
||||
func TestPDFDeadline(t *testing.T) {
|
||||
f := loopbackFetcher()
|
||||
f.Timeout = 250 * time.Millisecond
|
||||
f.PDFTimeout = 3 * time.Second
|
||||
if _, err := f.Fetch(context.Background(), slow(t, "application/pdf", "%PDF-1.7\n")); err != nil {
|
||||
t.Errorf("slow pdf under PDFTimeout: %v", err)
|
||||
}
|
||||
if _, err := f.Fetch(context.Background(), slow(t, "application/octet-stream", "%PDF-1.7\n")); err != nil {
|
||||
t.Errorf("slow sniffed pdf under PDFTimeout: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := f.Fetch(context.Background(), slow(t, "text/html", "<html>")); err == nil {
|
||||
t.Errorf("slow html past Timeout succeeded; want an error")
|
||||
}
|
||||
if d := time.Since(start); d > 2*time.Second {
|
||||
t.Errorf("slow html took %v to fail; the 250 ms deadline was not applied", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIf(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
got = append(got, r.Header.Get("If-None-Match")+"|"+r.Header.Get("If-Modified-Since"))
|
||||
mu.Unlock()
|
||||
if r.Header.Get("If-None-Match") == `"v1"` {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("ETag", `"v2"`)
|
||||
w.Header().Set("Last-Modified", "Fri, 25 Sep 2026 12:00:00 GMT")
|
||||
_, _ = w.Write([]byte("%PDF-1.7\nnew"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := loopbackFetcher()
|
||||
|
||||
res, err := f.FetchIf(context.Background(), ts.URL, `"v1"`, "")
|
||||
if err != nil || res == nil || res.Status != http.StatusNotModified || len(res.Body) != 0 {
|
||||
t.Fatalf("matching ETag: res %+v err %v; want status 304, empty body, nil error", res, err)
|
||||
}
|
||||
res, err = f.FetchIf(context.Background(), ts.URL, `"old"`, "Thu, 24 Sep 2026 12:00:00 GMT")
|
||||
if err != nil || res.Status != 200 || string(res.Body) != "%PDF-1.7\nnew" || res.Header.Get("ETag") != `"v2"` {
|
||||
t.Fatalf("changed document: res %+v err %v; want 200 with the new body and ETag", res, err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 2 || got[0] != `"v1"|` || got[1] != `"old"|Thu, 24 Sep 2026 12:00:00 GMT` {
|
||||
t.Errorf("validators sent: %q", got)
|
||||
}
|
||||
// With no validators FetchIf is a plain Fetch.
|
||||
if _, err := f.FetchIf(context.Background(), ts.URL, "", ""); err != nil {
|
||||
t.Errorf("FetchIf with no validators: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package metrics
|
||||
|
||||
// P5a task 07: the sift_pdf_* series.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type zeroSrc struct{}
|
||||
|
||||
func (zeroSrc) Snapshot() Snapshot { return Snapshot{} }
|
||||
|
||||
func TestPDFSeries(t *testing.T) {
|
||||
s := New(zeroSrc{})
|
||||
s.ObservePDF("ok", 1500*time.Millisecond, 212)
|
||||
s.ObservePDF("ok", 200*time.Millisecond, 3)
|
||||
s.ObservePDF("no_text", 50*time.Millisecond, 0)
|
||||
s.ObservePDF("blocked: evil.example", time.Millisecond, 0) // raw text never becomes a label
|
||||
s.ObservePDFWait(2 * time.Second)
|
||||
s.PDFCacheHit()
|
||||
s.PDFCacheHit()
|
||||
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
req.RemoteAddr, req.Host = "127.0.0.1:9", "127.0.0.1:8890"
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
out := string(body)
|
||||
for _, want := range []string{
|
||||
`sift_pdf_extract_total{outcome="ok"} 2`,
|
||||
`sift_pdf_extract_total{outcome="no_text"} 1`,
|
||||
`sift_pdf_extract_total{outcome="other"} 1`,
|
||||
`sift_pdf_pages_total 215`,
|
||||
`sift_pdf_cache_hits_total 2`,
|
||||
`sift_pdf_extract_seconds_count 4`,
|
||||
`sift_pdf_pool_wait_seconds_count 1`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("metrics lack %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "evil.example") {
|
||||
t.Error("a raw outcome string leaked into a label")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package pdfdoc_test
|
||||
|
||||
// P5a task 05: rendering an extracted document for an agent — the whole text with page markers,
|
||||
// the map for a long document, a page range, and grep.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdfdoc"
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdftext"
|
||||
)
|
||||
|
||||
func small() *pdftext.Doc {
|
||||
return &pdftext.Doc{
|
||||
Meta: pdftext.Meta{Pages: 3, Title: "Three Pages", Outline: []pdftext.OutlineItem{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "2.1 Den", Page: 2, Level: 2}}},
|
||||
Text: []string{"Chapter one begins here.\nSecond line.\n", " The quick brown fox is on page two.\nIt jumps.", "Last page."},
|
||||
}
|
||||
}
|
||||
|
||||
// big returns n pages of about 1,000 characters each, every line naming its page.
|
||||
func big(n int) *pdftext.Doc {
|
||||
d := &pdftext.Doc{Meta: pdftext.Meta{Pages: n, Title: "Long Thesis", Outline: []pdftext.OutlineItem{{Title: "1 Introduction", Page: 1, Level: 1}, {Title: "2 Method", Page: 12, Level: 1}, {Title: "2.3 Setup", Page: 15, Level: 2}}}}
|
||||
for p := 1; p <= n; p++ {
|
||||
var lines []string
|
||||
for l := 1; l <= 12; l++ {
|
||||
lines = append(lines, fmt.Sprintf("page %d line %d lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod", p, l))
|
||||
}
|
||||
if p == 37 {
|
||||
lines[4] = "the Needle is here on page 37"
|
||||
}
|
||||
d.Text = append(d.Text, strings.Join(lines, "\n"))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestFull(t *testing.T) {
|
||||
want := "--- page 1 ---\n\nChapter one begins here.\nSecond line.\n\n--- page 2 ---\n\nThe quick brown fox is on page two.\nIt jumps.\n\n--- page 3 ---\n\nLast page."
|
||||
if got := pdfdoc.Full(small()); got != want {
|
||||
t.Errorf("Full =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
if pdfdoc.Marker(12) != "--- page 12 ---" {
|
||||
t.Errorf("Marker(12) = %q", pdfdoc.Marker(12))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWholeDocumentWhenItFits(t *testing.T) {
|
||||
r, err := pdfdoc.Render(small(), pdfdoc.Options{OutCap: 10000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Map || r.First != 1 || r.Last != 3 || r.Content != pdfdoc.Full(small()) {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapForALongDocument(t *testing.T) {
|
||||
d := big(60) // ~60k characters
|
||||
r, err := pdfdoc.Render(d, pdfdoc.Options{OutCap: 20000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Map || r.First != 1 || r.Last < 1 {
|
||||
t.Fatalf("got Map=%v First=%d Last=%d, want a map from page 1", r.Map, r.First, r.Last)
|
||||
}
|
||||
c := r.Content
|
||||
for _, want := range []string{
|
||||
"# Long Thesis\n",
|
||||
"60 pages · ~",
|
||||
"## Outline\n",
|
||||
"- 1 Introduction (p. 1)\n",
|
||||
"- 2 Method (p. 12)\n",
|
||||
" - 2.3 Setup (p. 15)\n", // level 2 is indented two spaces
|
||||
`pages="a-b"`,
|
||||
`grep="pattern"`,
|
||||
"--- page 1 ---",
|
||||
} {
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("map lacks %q", want)
|
||||
}
|
||||
}
|
||||
if utf8.RuneCountInString(c) > 20000 {
|
||||
t.Errorf("map is %d characters, over OutCap 20000", utf8.RuneCountInString(c))
|
||||
}
|
||||
// Leading pages take at most a quarter of the cap, and only whole pages.
|
||||
if strings.Contains(c, fmt.Sprintf("--- page %d ---", r.Last+1)) {
|
||||
t.Errorf("content has page %d past Last %d", r.Last+1, r.Last)
|
||||
}
|
||||
if !strings.Contains(c, fmt.Sprintf("page %d line 12", r.Last)) {
|
||||
t.Errorf("the last page shown (%d) is cut; pages in a map are whole", r.Last)
|
||||
}
|
||||
idx := strings.Index(c, "--- page 1 ---")
|
||||
if pagesPart := c[idx:]; utf8.RuneCountInString(pagesPart) > 20000/4 {
|
||||
t.Errorf("leading pages take %d characters, over OutCap/4", utf8.RuneCountInString(pagesPart))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNotesPartialExtraction(t *testing.T) {
|
||||
d := big(30)
|
||||
d.Pages = 2500 // the child stopped at its page cap
|
||||
r, _ := pdfdoc.Render(d, pdfdoc.Options{OutCap: 5000})
|
||||
if !strings.Contains(r.Content, "2500 pages") || !strings.Contains(r.Content, "pages 1-30 extracted") {
|
||||
t.Errorf("map must say how much was extracted:\n%s", r.Content[:min(600, len(r.Content))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntitledAndNoOutline(t *testing.T) {
|
||||
d := big(40)
|
||||
d.Title, d.Outline = "", nil
|
||||
r, _ := pdfdoc.Render(d, pdfdoc.Options{OutCap: 5000})
|
||||
if !strings.HasPrefix(r.Content, "# Untitled PDF\n") || strings.Contains(r.Content, "## Outline") {
|
||||
t.Errorf("untitled, outline-less map:\n%s", r.Content[:min(400, len(r.Content))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPages(t *testing.T) {
|
||||
d := big(60)
|
||||
for _, tc := range []struct {
|
||||
spec string
|
||||
first, last int
|
||||
}{
|
||||
{"2-3", 2, 3},
|
||||
{"7", 7, 7},
|
||||
{" 58- ", 58, 60},
|
||||
{"60-60", 60, 60},
|
||||
} {
|
||||
r, err := pdfdoc.Render(d, pdfdoc.Options{Pages: tc.spec, OutCap: 200000})
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", tc.spec, err)
|
||||
continue
|
||||
}
|
||||
if r.Map || r.First != tc.first || r.Last != tc.last {
|
||||
t.Errorf("%q: First %d Last %d Map %v, want %d-%d", tc.spec, r.First, r.Last, r.Map, tc.first, tc.last)
|
||||
}
|
||||
if !strings.HasPrefix(r.Content, pdfdoc.Marker(tc.first)+"\n\n") || strings.Contains(r.Content, pdfdoc.Marker(tc.last+1)) || (tc.first > 1 && strings.Contains(r.Content, pdfdoc.Marker(tc.first-1)+"\n")) {
|
||||
t.Errorf("%q: wrong pages in content: %.120q…", tc.spec, r.Content)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"0-1", "5-4", "61", "999", "a-b", "1-2-3", "", "-5"} {
|
||||
if bad == "" {
|
||||
continue // empty means "no range", tested above
|
||||
}
|
||||
_, err := pdfdoc.Render(d, pdfdoc.Options{Pages: bad, OutCap: 200000})
|
||||
if !errors.Is(err, pdfdoc.ErrBadPages) {
|
||||
t.Errorf("%q: %v, want ErrBadPages", bad, err)
|
||||
}
|
||||
}
|
||||
_, err := pdfdoc.Render(d, pdfdoc.Options{Pages: "61", OutCap: 200000})
|
||||
if err == nil || !strings.Contains(err.Error(), "pages out of range (1-60)") {
|
||||
t.Errorf("out-of-range message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A range is at most 100 pages and at most OutCap characters, cut at a whole page with a note
|
||||
// that says how to continue.
|
||||
func TestPagesAreCapped(t *testing.T) {
|
||||
d := big(300)
|
||||
r, err := pdfdoc.Render(d, pdfdoc.Options{Pages: "1-300", OutCap: 1 << 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Last != 100 || !strings.Contains(r.Note, `pages="101-300"`) {
|
||||
t.Errorf("1-300: Last %d note %q; want 100 and a pointer to 101-300", r.Last, r.Note)
|
||||
}
|
||||
r, _ = pdfdoc.Render(d, pdfdoc.Options{Pages: "10-40", OutCap: 5000})
|
||||
if utf8.RuneCountInString(r.Content) > 5000 || r.First != 10 || r.Last < 10 || r.Last >= 40 {
|
||||
t.Errorf("10-40 under a 5000 cap: First %d Last %d, %d chars", r.First, r.Last, utf8.RuneCountInString(r.Content))
|
||||
}
|
||||
if !strings.Contains(r.Note, fmt.Sprintf(`pages="%d-40"`, r.Last+1)) {
|
||||
t.Errorf("note %q must point at the rest", r.Note)
|
||||
}
|
||||
// One page bigger than the cap is truncated rather than dropped.
|
||||
huge := &pdftext.Doc{Meta: pdftext.Meta{Pages: 1}, Text: []string{strings.Repeat("w ", 10000)}}
|
||||
r, _ = pdfdoc.Render(huge, pdfdoc.Options{Pages: "1", OutCap: 1000})
|
||||
if r.First != 1 || r.Last != 1 || utf8.RuneCountInString(r.Content) > 1000 || r.Note == "" {
|
||||
t.Errorf("oversized single page: %+v (%d chars)", r, utf8.RuneCountInString(r.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrep(t *testing.T) {
|
||||
d := big(60)
|
||||
r, err := pdfdoc.Render(d, pdfdoc.Options{Grep: "needle", OutCap: 200000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.GrepHits != 1 || r.First != 0 || r.Map {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`grep "needle": 1 match`,
|
||||
"p. 37- page 37 line 4",
|
||||
"p. 37: the Needle is here on page 37",
|
||||
"p. 37- page 37 line 6",
|
||||
} {
|
||||
if !strings.Contains(r.Content, want) {
|
||||
t.Errorf("grep output lacks %q:\n%s", want, r.Content)
|
||||
}
|
||||
}
|
||||
// Smart case: an upper-case letter makes the pattern case-sensitive.
|
||||
if r, _ := pdfdoc.Render(d, pdfdoc.Options{Grep: "NEEDLE", OutCap: 200000}); r.GrepHits != 0 {
|
||||
t.Errorf("NEEDLE matched %d times, want 0 (case-sensitive)", r.GrepHits)
|
||||
}
|
||||
if r, _ := pdfdoc.Render(d, pdfdoc.Options{Grep: "Needle", OutCap: 200000}); r.GrepHits != 1 {
|
||||
t.Errorf("Needle matched %d times, want 1", r.GrepHits)
|
||||
}
|
||||
// No match is an answer, not an error.
|
||||
r, err = pdfdoc.Render(d, pdfdoc.Options{Grep: "zebra", OutCap: 200000})
|
||||
if err != nil || r.GrepHits != 0 || !strings.Contains(r.Content, `grep "zebra": no matches`) {
|
||||
t.Errorf("no match: %+v %v", r, err)
|
||||
}
|
||||
// Within a page range.
|
||||
r, _ = pdfdoc.Render(d, pdfdoc.Options{Grep: "line 3 ", Pages: "5-6", OutCap: 200000})
|
||||
if r.GrepHits != 2 || strings.Contains(r.Content, "p. 7") {
|
||||
t.Errorf("grep in 5-6: %d hits\n%s", r.GrepHits, r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepLimits(t *testing.T) {
|
||||
d := big(200)
|
||||
r, err := pdfdoc.Render(d, pdfdoc.Options{Grep: "lorem", OutCap: 1 << 30}) // 2,399 matches: every line but the needle
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.GrepHits != 2399 || strings.Count(r.Content, ": page ") != 100 || !strings.Contains(r.Content, "showing the first 100") {
|
||||
t.Errorf("hits %d, shown %d", r.GrepHits, strings.Count(r.Content, ": page "))
|
||||
}
|
||||
if _, err := pdfdoc.Render(d, pdfdoc.Options{Grep: strings.Repeat("a", 201), OutCap: 1000}); !errors.Is(err, pdfdoc.ErrBadGrep) {
|
||||
t.Errorf("201-byte pattern: %v, want ErrBadGrep", err)
|
||||
}
|
||||
if _, err := pdfdoc.Render(d, pdfdoc.Options{Grep: "(unclosed", OutCap: 1000}); !errors.Is(err, pdfdoc.ErrBadGrep) {
|
||||
t.Errorf("bad regexp: %v, want ErrBadGrep", err)
|
||||
}
|
||||
// RE2 is linear: the classic catastrophic pattern over a 1 MB page finishes quickly.
|
||||
evil := &pdftext.Doc{Meta: pdftext.Meta{Pages: 1}, Text: []string{strings.Repeat("a", 1<<20) + "!"}}
|
||||
start := time.Now()
|
||||
_, _ = pdfdoc.Render(evil, pdfdoc.Options{Grep: "(a+)+$", OutCap: 1000})
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Errorf("(a+)+$ took %v", d)
|
||||
}
|
||||
// Output stays under the cap.
|
||||
r, _ = pdfdoc.Render(d, pdfdoc.Options{Grep: "lorem", OutCap: 3000})
|
||||
if utf8.RuneCountInString(r.Content) > 3000 {
|
||||
t.Errorf("grep output %d characters over a 3000 cap", utf8.RuneCountInString(r.Content))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package pdfpool_test
|
||||
|
||||
// P5a task 04: the extraction pool. A few slots, a short queue, single-flight per key, and at
|
||||
// most one job in flight per caller.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
||||
)
|
||||
|
||||
func waitUntil(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not reached within 3 s")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleFlight(t *testing.T) {
|
||||
p := pdfpool.New(2, 4, time.Second)
|
||||
var calls atomic.Int32
|
||||
release := make(chan struct{})
|
||||
fn := func(ctx context.Context) (any, error) {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return "text", nil
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan any, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
v, err := p.Do(context.Background(), "doc-1", "caller-"+string(rune('a'+i)), fn)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
results <- v
|
||||
}(i)
|
||||
}
|
||||
waitUntil(t, func() bool { return calls.Load() == 1 && p.Waiting("doc-1") == 4 })
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for v := range results {
|
||||
if v != "text" {
|
||||
t.Errorf("a sharer got %v", v)
|
||||
}
|
||||
}
|
||||
if n := calls.Load(); n != 1 {
|
||||
t.Errorf("fn ran %d times for one key, want 1", n)
|
||||
}
|
||||
// Nothing is cached: the next call runs fn again.
|
||||
_, _ = p.Do(context.Background(), "doc-1", "x", func(ctx context.Context) (any, error) { calls.Add(1); return nil, nil })
|
||||
if n := calls.Load(); n != 2 {
|
||||
t.Errorf("after the flight ended, fn ran %d times in total, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Two slots and four waiting places: the seventh distinct job is refused at once.
|
||||
func TestSlotsAndQueue(t *testing.T) {
|
||||
p := pdfpool.New(2, 4, 5*time.Second)
|
||||
release := make(chan struct{})
|
||||
var running atomic.Int32
|
||||
block := func(ctx context.Context) (any, error) {
|
||||
running.Add(1)
|
||||
defer running.Add(-1)
|
||||
<-release
|
||||
return nil, nil
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if _, err := p.Do(context.Background(), "doc-"+string(rune('a'+i)), "caller-"+string(rune('a'+i)), block); err != nil {
|
||||
t.Errorf("job %d: %v", i, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
waitUntil(t, func() bool { return running.Load() == 2 && p.Queued() == 4 })
|
||||
start := time.Now()
|
||||
_, err := p.Do(context.Background(), "doc-z", "caller-z", block)
|
||||
if !errors.Is(err, pdfpool.ErrBusy) {
|
||||
t.Errorf("seventh job: %v, want ErrBusy", err)
|
||||
}
|
||||
if d := time.Since(start); d > 200*time.Millisecond {
|
||||
t.Errorf("a full queue refused after %v; it must refuse at once", d)
|
||||
}
|
||||
if running.Load() > 2 {
|
||||
t.Errorf("%d jobs ran at once, want at most 2", running.Load())
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// A queued job that waits longer than Wait is refused with ErrBusy and gives up its place.
|
||||
func TestQueueWait(t *testing.T) {
|
||||
p := pdfpool.New(1, 1, 150*time.Millisecond)
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
go func() {
|
||||
_, _ = p.Do(context.Background(), "a", "ca", func(ctx context.Context) (any, error) { <-release; return nil, nil })
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||
start := time.Now()
|
||||
_, err := p.Do(context.Background(), "b", "cb", func(ctx context.Context) (any, error) { return nil, nil })
|
||||
if !errors.Is(err, pdfpool.ErrBusy) {
|
||||
t.Fatalf("waiter: %v, want ErrBusy", err)
|
||||
}
|
||||
if d := time.Since(start); d < 100*time.Millisecond || d > time.Second {
|
||||
t.Errorf("waiter refused after %v, want about 150 ms", d)
|
||||
}
|
||||
if p.Queued() != 0 {
|
||||
t.Errorf("queued = %d after the waiter gave up, want 0", p.Queued())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallerBusy(t *testing.T) {
|
||||
p := pdfpool.New(2, 4, time.Second)
|
||||
release := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = p.Do(context.Background(), "doc-1", "straylight", func(ctx context.Context) (any, error) { <-release; return nil, nil })
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||
if _, err := p.Do(context.Background(), "doc-2", "straylight", func(ctx context.Context) (any, error) { return nil, nil }); !errors.Is(err, pdfpool.ErrCallerBusy) {
|
||||
t.Errorf("second job from the same caller: %v, want ErrCallerBusy", err)
|
||||
}
|
||||
// The same caller asking for the same document again joins the flight instead.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Do(context.Background(), "doc-1", "straylight", func(ctx context.Context) (any, error) { return nil, nil })
|
||||
done <- err
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Waiting("doc-1") == 1 })
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("joining its own flight: %v", err)
|
||||
}
|
||||
// Once the job is done the caller may start another.
|
||||
if _, err := p.Do(context.Background(), "doc-2", "straylight", func(ctx context.Context) (any, error) { return nil, nil }); err != nil {
|
||||
t.Errorf("after the first job ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsAndPanicsReachEverySharer(t *testing.T) {
|
||||
p := pdfpool.New(2, 4, time.Second)
|
||||
boom := errors.New("boom")
|
||||
if _, err := p.Do(context.Background(), "k", "c", func(ctx context.Context) (any, error) { return nil, boom }); !errors.Is(err, boom) {
|
||||
t.Errorf("fn error: %v", err)
|
||||
}
|
||||
_, err := p.Do(context.Background(), "k", "c", func(ctx context.Context) (any, error) { panic("kaboom") })
|
||||
if err == nil {
|
||||
t.Fatal("a panicking fn returned no error")
|
||||
}
|
||||
if p.Running() != 0 {
|
||||
t.Errorf("running = %d after a panic, want 0 (the slot leaked)", p.Running())
|
||||
}
|
||||
if _, err := p.Do(context.Background(), "k2", "c", func(ctx context.Context) (any, error) { return 1, nil }); err != nil {
|
||||
t.Errorf("the caller is still marked busy after its job panicked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancelWhileQueued(t *testing.T) {
|
||||
p := pdfpool.New(1, 2, 5*time.Second)
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
go func() {
|
||||
_, _ = p.Do(context.Background(), "a", "ca", func(ctx context.Context) (any, error) { <-release; return nil, nil })
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Do(ctx, "b", "cb", func(ctx context.Context) (any, error) { return nil, nil })
|
||||
errc <- err
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Queued() == 1 })
|
||||
cancel()
|
||||
if err := <-errc; !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("cancelled waiter: %v, want context.Canceled", err)
|
||||
}
|
||||
if p.Queued() != 0 {
|
||||
t.Errorf("queued = %d after cancel, want 0", p.Queued())
|
||||
}
|
||||
}
|
||||
|
||||
// The job's own context is not the first caller's: if the caller that started a shared flight
|
||||
// goes away, the sharers still get the result.
|
||||
func TestFlightOutlivesItsStarter(t *testing.T) {
|
||||
p := pdfpool.New(1, 2, 5*time.Second)
|
||||
release := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
jobCtxErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, _ = p.Do(ctx, "doc", "first", func(jctx context.Context) (any, error) {
|
||||
<-release
|
||||
jobCtxErr <- jctx.Err()
|
||||
return "done", nil
|
||||
})
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Running() == 1 })
|
||||
res := make(chan any, 1)
|
||||
go func() {
|
||||
v, _ := p.Do(context.Background(), "doc", "second", nil)
|
||||
res <- v
|
||||
}()
|
||||
waitUntil(t, func() bool { return p.Waiting("doc") == 1 })
|
||||
cancel()
|
||||
close(release)
|
||||
if v := <-res; v != "done" {
|
||||
t.Errorf("sharer got %v after the starter left, want done", v)
|
||||
}
|
||||
if e := <-jobCtxErr; e != nil {
|
||||
t.Errorf("the job's context was cancelled with its starter: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package pdftest builds small, deterministic PDFs for tests: text pages in Helvetica, an optional
|
||||
// outline (bookmarks), and image-only ("scanned") pages. It exists so the P5a tests need no binary
|
||||
// fixtures from elsewhere. It is test support: nothing outside _test.go files imports it.
|
||||
package pdftest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Outline is one bookmark: a title that points at a 1-based page, nested at Level (1 = top).
|
||||
// Build supports one level; Level is kept so tests can state what they expect back.
|
||||
type Outline struct {
|
||||
Title string
|
||||
Page int
|
||||
Level int
|
||||
}
|
||||
|
||||
// Doc describes a PDF to build. Pages holds each page's text; a page's lines are split on "\n"
|
||||
// and set one per line (long lines are not wrapped: callers keep them under ~90 characters).
|
||||
// Scanned pages (1-based) carry a small image and no text at all.
|
||||
type Doc struct {
|
||||
Title string
|
||||
Pages []string
|
||||
Outline []Outline
|
||||
Scanned []int
|
||||
}
|
||||
|
||||
// Build returns the PDF bytes for d. It panics on a Doc it cannot express (an outline entry
|
||||
// pointing past the last page), since that is a bug in the test that built it.
|
||||
func Build(d Doc) []byte {
|
||||
var b bytes.Buffer
|
||||
var offsets []int
|
||||
obj := func(body string) int {
|
||||
offsets = append(offsets, b.Len())
|
||||
n := len(offsets)
|
||||
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", n, body)
|
||||
return n
|
||||
}
|
||||
b.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
|
||||
// Object numbers are fixed by position: 1 catalog, 2 pages, 3 font, 4 image, 5 info, then
|
||||
// two per page (page, content), then the outline.
|
||||
nPages := len(d.Pages)
|
||||
firstPage := 6
|
||||
pageObj := func(i int) int { return firstPage + 2*i }
|
||||
outlineRoot := firstPage + 2*nPages
|
||||
scanned := map[int]bool{}
|
||||
for _, p := range d.Scanned {
|
||||
scanned[p] = true
|
||||
}
|
||||
|
||||
catalog := "<< /Type /Catalog /Pages 2 0 R"
|
||||
if len(d.Outline) > 0 {
|
||||
catalog += fmt.Sprintf(" /Outlines %d 0 R /PageMode /UseOutlines", outlineRoot)
|
||||
}
|
||||
obj(catalog + " >>")
|
||||
var kids []string
|
||||
for i := range d.Pages {
|
||||
kids = append(kids, fmt.Sprintf("%d 0 R", pageObj(i)))
|
||||
}
|
||||
obj(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), nPages))
|
||||
obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
||||
img := []byte{0x00, 0x80, 0x80, 0x00} // 2x2 grey pixels
|
||||
obj(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length %d >>\nstream\n%s\nendstream", len(img), img))
|
||||
obj(fmt.Sprintf("<< /Title (%s) /Producer (sift pdftest) >>", esc(d.Title)))
|
||||
|
||||
for i, text := range d.Pages {
|
||||
var content string
|
||||
if scanned[i+1] {
|
||||
content = "q 400 0 0 500 100 150 cm /Im1 Do Q"
|
||||
} else {
|
||||
var c strings.Builder
|
||||
c.WriteString("BT /F1 11 Tf 13 TL 60 760 Td\n")
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
fmt.Fprintf(&c, "(%s) Tj T*\n", esc(line))
|
||||
}
|
||||
c.WriteString("ET")
|
||||
content = c.String()
|
||||
}
|
||||
obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents %d 0 R >>", pageObj(i)+1))
|
||||
obj(fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content))
|
||||
}
|
||||
|
||||
if len(d.Outline) > 0 {
|
||||
first := outlineRoot + 1
|
||||
last := outlineRoot + len(d.Outline)
|
||||
obj(fmt.Sprintf("<< /Type /Outlines /First %d 0 R /Last %d 0 R /Count %d >>", first, last, len(d.Outline)))
|
||||
for i, o := range d.Outline {
|
||||
if o.Page < 1 || o.Page > nPages {
|
||||
panic(fmt.Sprintf("pdftest: outline %q points at page %d of %d", o.Title, o.Page, nPages))
|
||||
}
|
||||
n := first + i
|
||||
item := fmt.Sprintf("<< /Title (%s) /Parent %d 0 R /Dest [%d 0 R /XYZ null null null]", esc(o.Title), outlineRoot, pageObj(o.Page-1))
|
||||
if i > 0 {
|
||||
item += fmt.Sprintf(" /Prev %d 0 R", n-1)
|
||||
}
|
||||
if n < last {
|
||||
item += fmt.Sprintf(" /Next %d 0 R", n+1)
|
||||
}
|
||||
obj(item + " >>")
|
||||
}
|
||||
}
|
||||
|
||||
xref := b.Len()
|
||||
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
|
||||
for _, off := range offsets {
|
||||
fmt.Fprintf(&b, "%010d 00000 n \n", off)
|
||||
}
|
||||
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R /Info 5 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// Lorem returns n lines of distinct, searchable filler: "p<page> l<line> …", so a test can tell
|
||||
// which page a line came from.
|
||||
func Lorem(page, n int) string {
|
||||
words := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"}
|
||||
var lines []string
|
||||
for i := 1; i <= n; i++ {
|
||||
var w []string
|
||||
for j := 0; j < 10; j++ {
|
||||
w = append(w, words[(page*7+i*3+j)%len(words)])
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("p%d l%d %s", page, i, strings.Join(w, " ")))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func esc(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user