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>
119 lines
3.7 KiB
Go
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
|
|
}
|