Files
money/internal/parser/bundled.go
T
nikolaandClaude Opus 5.5 5847245638 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>
2026-10-02 18:02:46 +02:00

119 lines
3.7 KiB
Go

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
}