Bundle a static pdftotext into the binary
The PDF parsers shell out to pdftotext, so every machine running money needed poppler-utils installed. scripts/build-bundled.sh now builds a deployable binary that carries its own: pdftotext is compiled in a container from a checksum-pinned poppler release as a fully static musl executable, then embedded with `go build -tags bundled`. The result is one file that runs on any Linux of that architecture with nothing installed alongside it. It is still the real pdftotext, run as a subprocess. Linking poppler through cgo would have cost the pure-Go build, and its C++ text API is not guaranteed to space columns the way pdftotext -layout does, which is what the parsers were tuned on. Only what text extraction needs is compiled in -- no fontconfig, cairo or image codecs -- and its output is byte-identical to a full distro build on the same PDF. At runtime the embedded copy is written to the user cache directory, not /tmp, which servers often mount noexec. It is named by content hash, so a newer build never runs an older copy, and verified before reuse, so a write cut short by a killed process is replaced rather than trusted. `money config` says which pdftotext is in use. The tag is opt-in: plain go build and go test never need the 5 MB executable, which is gitignored rather than committed. Building with the tag for anything but linux/amd64 or linux/arm64 fails with a message saying so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// bundledPdftotext is a static pdftotext built into this binary, or empty
|
||||
// when it was built without one. Only `go build -tags bundled` fills it (see
|
||||
// scripts/build-bundled.sh), so an ordinary build stays pure Go and needs no
|
||||
// 5 MB executable checked out next to it.
|
||||
var bundledPdftotext []byte
|
||||
|
||||
var (
|
||||
bundledOnce sync.Once
|
||||
bundledPath string
|
||||
bundledErr error
|
||||
)
|
||||
|
||||
// pdftotextCommand names the pdftotext to run: the bundled copy when there is
|
||||
// one, otherwise whatever is on PATH. The bundled copy wins because it is the
|
||||
// version the binary was built and checked with, which is the point of
|
||||
// carrying it rather than trusting the host's.
|
||||
func pdftotextCommand() (string, error) {
|
||||
if len(bundledPdftotext) == 0 {
|
||||
return "pdftotext", nil
|
||||
}
|
||||
bundledOnce.Do(func() { bundledPath, bundledErr = installBundled(bundledPdftotext) })
|
||||
return bundledPath, bundledErr
|
||||
}
|
||||
|
||||
// PdftotextSource says which pdftotext the PDF parsers will run, for
|
||||
// `money config`: a deployment that lacks one should find out before an
|
||||
// import fails on it.
|
||||
func PdftotextSource() string {
|
||||
if len(bundledPdftotext) == 0 {
|
||||
return "pdftotext from PATH (not bundled into this build)"
|
||||
}
|
||||
path, err := pdftotextCommand()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("bundled, but it cannot be installed: %v", err)
|
||||
}
|
||||
return "bundled, run from " + path
|
||||
}
|
||||
|
||||
// installBundled writes the executable to the user's cache directory, where it
|
||||
// can be exec'd, and returns its path. The name carries the content hash, so a
|
||||
// newer build never runs an older build's copy, and a file already there is
|
||||
// only reused once its contents check out — a truncated write from a killed
|
||||
// process must not become the pdftotext every later run trusts.
|
||||
func installBundled(bin []byte) (string, error) {
|
||||
sum := sha256.Sum256(bin)
|
||||
name := "pdftotext-" + hex.EncodeToString(sum[:8])
|
||||
|
||||
// The cache directory rather than /tmp: /tmp is often mounted noexec on
|
||||
// servers, and the cache survives reboots, so this happens once per build.
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
dir = filepath.Join(dir, "money")
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
if same, err := hasContents(path, sum); err != nil {
|
||||
return "", err
|
||||
} else if same {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("install bundled pdftotext: %w", err)
|
||||
}
|
||||
// Written beside the target and renamed into place, so a concurrent run
|
||||
// never execs a half-written file.
|
||||
tmp, err := os.CreateTemp(dir, name+".*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("install bundled pdftotext: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // a no-op once renamed
|
||||
if _, err := tmp.Write(bin); err != nil {
|
||||
tmp.Close()
|
||||
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||
}
|
||||
if err := tmp.Chmod(0o755); err != nil {
|
||||
tmp.Close()
|
||||
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return "", fmt.Errorf("install bundled pdftotext to %s: %w", dir, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// hasContents reports whether the file at path exists and hashes to sum.
|
||||
func hasContents(path string, sum [sha256.Size]byte) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check bundled pdftotext: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return false, fmt.Errorf("check bundled pdftotext: %w", err)
|
||||
}
|
||||
return bytes.Equal(h.Sum(nil), sum[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build bundled
|
||||
|
||||
package parser
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Built by scripts/build-bundled.sh amd64, which also runs the go build that
|
||||
// needs it. The file is not checked in, so this only compiles after that.
|
||||
//
|
||||
//go:embed bundled/pdftotext-linux-amd64
|
||||
var embeddedPdftotext []byte
|
||||
|
||||
func init() { bundledPdftotext = embeddedPdftotext }
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build bundled
|
||||
|
||||
package parser
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Built by scripts/build-bundled.sh arm64, which also runs the go build that
|
||||
// needs it. The file is not checked in, so this only compiles after that.
|
||||
//
|
||||
//go:embed bundled/pdftotext-linux-arm64
|
||||
var embeddedPdftotext []byte
|
||||
|
||||
func init() { bundledPdftotext = embeddedPdftotext }
|
||||
@@ -0,0 +1,79 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakePdftotext stands in for the real executable: what matters here is how it
|
||||
// is put on disk, not what it does once there.
|
||||
var fakePdftotext = []byte("#!/bin/sh\necho bundled\n")
|
||||
|
||||
// The bundled copy has to land somewhere it can be exec'd, and run.
|
||||
func TestInstallBundledRuns(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
path, err := installBundled(fakePdftotext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command(path).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("run %s: %v", path, err)
|
||||
}
|
||||
if string(out) != "bundled\n" {
|
||||
t.Errorf("output %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A copy already in place is reused, but only once its contents check out: a
|
||||
// write cut short by a killed process must not become what every later run
|
||||
// trusts.
|
||||
func TestInstallBundledReplacesADamagedCopy(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
path, err := installBundled(fakePdftotext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, fakePdftotext[:5], 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := installBundled(fakePdftotext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != path {
|
||||
t.Errorf("reinstalled to %s, want %s", again, path)
|
||||
}
|
||||
if got, _ := os.ReadFile(path); string(got) != string(fakePdftotext) {
|
||||
t.Errorf("damaged copy kept: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Each build's copy has its own name, so a newer binary never runs an older
|
||||
// one's pdftotext left in the same cache.
|
||||
func TestInstallBundledNamesByContent(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
a, err := installBundled(fakePdftotext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := installBundled(append([]byte(nil), "#!/bin/sh\necho newer\n"...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b || filepath.Dir(a) != filepath.Dir(b) {
|
||||
t.Errorf("paths %s and %s, want two names in one directory", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Without -tags bundled, nothing is embedded and PATH decides, as before.
|
||||
func TestUnbundledUsesPath(t *testing.T) {
|
||||
if len(bundledPdftotext) != 0 {
|
||||
t.Skip("built with -tags bundled")
|
||||
}
|
||||
if cmd, err := pdftotextCommand(); err != nil || cmd != "pdftotext" {
|
||||
t.Errorf("command = %q, %v; want pdftotext from PATH", cmd, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build bundled && !(linux && (amd64 || arm64))
|
||||
|
||||
package parser
|
||||
|
||||
// pdftotext is only built for Linux, as a static musl executable; there is
|
||||
// nothing to embed for this platform. Build without -tags bundled and install
|
||||
// poppler-utils instead.
|
||||
var _ = bundled_pdftotext_is_only_available_for_linux_amd64_and_linux_arm64
|
||||
@@ -17,12 +17,18 @@ const pdfToTextTimeout = 2 * time.Minute
|
||||
//
|
||||
// This shells out to poppler's pdftotext rather than decoding the PDF in Go:
|
||||
// the layout reconstruction it does is the whole reason the column-based
|
||||
// parsers work, and no Go library matches it.
|
||||
// parsers work, and no Go library matches it. A build made with -tags bundled
|
||||
// carries its own copy, so a server needs nothing installed.
|
||||
func pdfToText(path string) (string, error) {
|
||||
bin, err := pdftotextCommand()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pdfToTextTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "pdftotext", "-layout", path, "-")
|
||||
cmd := exec.CommandContext(ctx, bin, "-layout", path, "-")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
@@ -34,6 +40,12 @@ func pdfToText(path string) (string, error) {
|
||||
if errors := strings.TrimSpace(stderr.String()); errors != "" {
|
||||
return "", fmt.Errorf("pdftotext %s: %w: %s", path, err, errors)
|
||||
}
|
||||
if bin != "pdftotext" {
|
||||
// The copy was installed, so failing to start it is about where it
|
||||
// was put: a cache directory on a noexec mount is the usual cause.
|
||||
return "", fmt.Errorf("bundled pdftotext at %s did not run (is that directory mounted noexec? "+
|
||||
"point XDG_CACHE_HOME somewhere else): %w", bin, err)
|
||||
}
|
||||
if _, lookErr := exec.LookPath("pdftotext"); lookErr != nil {
|
||||
return "", fmt.Errorf("pdftotext is not installed (it ships with poppler-utils): %w", lookErr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user