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 }