fix(pdf-cover): render page via go-pdfium WASM (bookshelf-uamr) #971

Merged
zombor merged 4 commits from bd-bookshelf-uamr into main 2026-07-06 02:45:00 +00:00
Owner

Summary

  • Replaces pdfcpu embedded-image extraction with go-pdfium (WASM/wazero backend) page rendering for PDF cover extraction
  • The old approach grabbed the first raster image embedded in the PDF — vector/text covers returned ErrNoCover and multi-image PDFs grabbed a logo instead of the cover
  • The new approach renders page 0 at up to 1200×1800px; any valid PDF (vector, text, raster) now yields a raster cover image
  • No CGo: pdfium.wasm is embedded via go:embed and compiled once at startup via sync.Once
  • Error classification preserved: corrupt/unrenderable/zero-page PDF → ErrNoCover (PERMANENT sentinel); wfengine.isPermanentCoverErr already covers this
  • WASM hardened: memory cap (~1GB via WithMemoryLimitPages(16384)), read-only host FS (WithReadOnlyDirMount), render deadline with interruptible execution (WithCloseOnContextDone + 60s timeout goroutine)

Security hardening (3 MAJORs fixed)

  1. WASM memory capWithMemoryLimitPages(pdfiumMaxMemoryPages) where pdfiumMaxMemoryPages = 16384 (~1 GB). A crafted PDF with heavily compressed streams can decompress to GBs in WASM linear memory; this caps growth.
  2. Read-only FSFSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/"). Previously go-pdfium defaulted to read-write host-root mount; pdfium page rendering needs only read access.
  3. Render deadline + interruptible WASMWithCloseOnContextDone(true) + context.WithTimeout(60s) goroutine in renderPDFCover. A crafted PDF that makes pdfium spin held the MaxTotal=1 pool instance forever; now the instance is released on timeout and subsequent renders succeed.

Test plan

  • Text/vector PDF renders to non-empty PNG bytes with positive dimensions
  • Raster-image PDF renders successfully
  • Corrupt/non-PDF data returns ErrNoCover (permanent)
  • Zero-page PDF (valid structure, /Count 0) returns ErrNoCover
  • ExtractCoverFromFile failing ReadAt returns error
  • Size guard still fires before pdfium is invoked (ErrCoverTooLarge)
  • Render deadline exceeded → error wrapping ErrNoCover, pool released (closeInstance called)
  • Subsequent render after deadline succeeds (proves no permanent pool exhaustion)
  • make test green, make coverage gate passes (100%), make lint 0 issues
  • No check-coverage.sh exclusions — all branches covered by real tests

Closes bead bookshelf-uamr on merge.

## Summary - Replaces `pdfcpu` embedded-image extraction with `go-pdfium` (WASM/wazero backend) page rendering for PDF cover extraction - The old approach grabbed the first raster image embedded in the PDF — vector/text covers returned `ErrNoCover` and multi-image PDFs grabbed a logo instead of the cover - The new approach renders page 0 at up to 1200×1800px; any valid PDF (vector, text, raster) now yields a raster cover image - No CGo: `pdfium.wasm` is embedded via `go:embed` and compiled once at startup via `sync.Once` - Error classification preserved: corrupt/unrenderable/zero-page PDF → `ErrNoCover` (PERMANENT sentinel); `wfengine.isPermanentCoverErr` already covers this - WASM hardened: memory cap (~1GB via `WithMemoryLimitPages(16384)`), read-only host FS (`WithReadOnlyDirMount`), render deadline with interruptible execution (`WithCloseOnContextDone` + 60s timeout goroutine) ## Security hardening (3 MAJORs fixed) 1. **WASM memory cap** — `WithMemoryLimitPages(pdfiumMaxMemoryPages)` where `pdfiumMaxMemoryPages = 16384` (~1 GB). A crafted PDF with heavily compressed streams can decompress to GBs in WASM linear memory; this caps growth. 2. **Read-only FS** — `FSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/")`. Previously go-pdfium defaulted to read-write host-root mount; pdfium page rendering needs only read access. 3. **Render deadline + interruptible WASM** — `WithCloseOnContextDone(true)` + `context.WithTimeout(60s)` goroutine in `renderPDFCover`. A crafted PDF that makes pdfium spin held the MaxTotal=1 pool instance forever; now the instance is released on timeout and subsequent renders succeed. ## Test plan - [x] Text/vector PDF renders to non-empty PNG bytes with positive dimensions - [x] Raster-image PDF renders successfully - [x] Corrupt/non-PDF data returns `ErrNoCover` (permanent) - [x] Zero-page PDF (valid structure, `/Count 0`) returns `ErrNoCover` - [x] `ExtractCoverFromFile` failing `ReadAt` returns error - [x] Size guard still fires before pdfium is invoked (`ErrCoverTooLarge`) - [x] Render deadline exceeded → error wrapping `ErrNoCover`, pool released (closeInstance called) - [x] Subsequent render after deadline succeeds (proves no permanent pool exhaustion) - [x] `make test` green, `make coverage` gate passes (100%), `make lint` 0 issues - [x] No `check-coverage.sh` exclusions — all branches covered by real tests Closes bead bookshelf-uamr on merge.
fix(pdf-cover): render page via go-pdfium WASM instead of extracting embedded images
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m54s
/ Lint (pull_request) Successful in 2m17s
/ E2E API (pull_request) Successful in 2m27s
/ Integration (pull_request) Successful in 3m25s
/ Test (pull_request) Successful in 4m14s
/ E2E Browser (pull_request) Successful in 4m55s
c6900c6980
Replace the pdfcpu embedded-image extraction approach with go-pdfium (WASM/wazero
backend) page rendering. The old approach extracted the FIRST raster image embedded
in the PDF — vector covers returned ErrNoCover and multi-image covers grabbed a
logo instead of the cover art.

The new approach renders page 0 at up to 1200×1800 pixels using pdfium's
RenderPageInPixels API. Any valid PDF (vector, text, raster) now yields a raster
cover image. No CGo; pdfium.wasm is embedded via go:embed and compiled at startup.

Error classification (HARD RULE preserved):
- Corrupt / unrenderable / zero-page PDF → ErrNoCover (PERMANENT sentinel)
- wfengine's isPermanentCoverErr already checks ErrNoCover — no wfengine change needed

Coverage: 100% gate passes via:
- New tests for text/vector PDF rendering, zero-page PDF (ErrNoCover),
  corrupt data (ErrNoCover), and failing ReadAt in ExtractCoverFromFile
- check-coverage.sh exclusions for 4 infrastructure/dead-code paths
  (pool init failure, GetInstance timeout, pdfium render error, png.Encode on Buffer)

Closes bead bookshelf-uamr on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(uamr): inject pdfium render seams and cover all 4 error branches
All checks were successful
/ JS Unit Tests (pull_request) Successful in 36s
/ E2E API (pull_request) Successful in 2m46s
/ Lint (pull_request) Successful in 3m45s
/ Integration (pull_request) Successful in 3m48s
/ E2E Browser (pull_request) Successful in 4m21s
/ Test (pull_request) Successful in 4m39s
28beb02d1f
Replace 4 coverage exclusions in check-coverage.sh with real tests.
Introduce pdfRenderSeams struct with injectable function fields for:
  - pdfium pool acquisition (getPool)
  - instance acquisition from the pool (getInstance)
  - RenderPageInPixels call (renderPagePixels)
  - png.Encode call (encodePNG)

Export test setters via cover_extract_export_test.go so package files_test
can stub each seam with an error. Add 8 Ginkgo specs (4 Describe blocks,
2 Its each) that exercise the previously-excluded error branches:
  - pool init failure → error propagates
  - GetInstance failure → error propagates
  - RenderPageInPixels failure → error wraps ErrNoCover
  - png.Encode failure → error wraps ErrNoCover

Delete all 4 exclusion entries from scripts/check-coverage.sh.
make lint + make test + make coverage all green with 100% on internal/files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

CODE REVIEW: APPROVED — bookshelf-uamr

Phase 0: DEMO Verification

No runnable DEMO block exists in the bead or PR body. This is a background-worker change (PDF cover extraction) with no interactive user surface to capture in a command DEMO. CI is green and the test plan is represented by the Ginkgo suite. The orchestrator has pre-cleared this; review proceeds to Phase 1.

Note: the PR body contains a stale statement — "4 infrastructure/dead-code paths excluded in check-coverage.sh with justification." The actual branch has zero net changes to scripts/check-coverage.sh (confirmed by empty diff). The seam-based tests replaced those exclusions. The PR body was not updated after the revision. No code impact — pure documentation drift.


Phase 1: Spec Compliance

All five review criteria verified against the diff:

  1. Page-0 render correctness (cover_extract.go:483-484): renders Index: 0 bounded by Width: maxPDFRenderWidth (1200), Height: maxPDFRenderHeight (1800). go-pdfium documents these as "The maximum width/height" — aspect ratio is preserved, so a wide page caps at 1200 px and a tall page caps at 1800 px. Both constants are documented with rationale. The existing maxPDFBytes (200 MB) size guard fires before pdfium is invoked. ✓

  2. Permanent error classification (cover_extract.go:469-496): corrupt/password-protected PDF → errors.Join(err, ErrNoCover) (permanent sentinel); zero-page PDF → bare ErrNoCover; render failure → errors.Join(err, ErrNoCover); PNG encode failure → errors.Join(encErr, ErrNoCover). All four path-end errors wrap ErrNoCover, which is already in wfengine.isPermanentCoverErr at cover_workflow.go:112. Pool-init and GetInstance failures do not wrap ErrNoCover — correctly treated as transient infrastructure failures. No go-workflows imports anywhere in internal/files/. ✓

  3. pdfRenderSeams DI (cover_extract.go:62-74): package-level struct; production defaults are bound to real pdfium/PNG functions; seams overridden only via cover_extract_export_test.go (sanctioned package files export file). All 8 error-branch It blocks make real assertions — pool failure asserts err occurred and result is nil; instance failure same; render failure asserts errors.Is(err, files.ErrNoCover) and nil bytes; PNG encode failure same. No assert-nothing tests. ✓

  4. Pool init is sync.Once, concurrency-safe (cover_extract.go:53-54, 96): pdfiumPoolOnce sync.Once guards webassembly.Init exactly once. MaxTotal: 1 serialises concurrent callers via GetInstance(pdfiumInstanceTimeout) (30 s timeout). Safe for the cover-generate worker. ✓

  5. check-coverage.sh: zero new exclusions — confirmed by empty diff. 100% coverage is achieved via the injectable seams. No new golangci.yml exclusions. ✓


Phase 2: Code Quality

No blockers. No majors. Three minors.

[MINOR] internal/files/cover_extract.goextractPDFCover (line ~448) is a one-line passthrough that just calls renderPDFCover. It adds an indirection layer with no caller distinction. Could be collapsed, but it's harmless.

[MINOR] internal/files/cover_extract.go:99 — Comment on getPdfiumPool says "Pool init failure is a transient condition...that may be retried." The sync.Once caches the failure for the process lifetime, so within a single process the failure is effectively permanent (wfengine burns MaxAttempts on fast-returning cached errors before giving up). The comment is accurate only at the process-restart level. No correctness bug — eventual failure is the correct outcome — but the wording could mislead a reader into expecting in-process retry to succeed.

[MINOR] internal/wfengine/cover_workflow.go:96 (on main, not this diff) — The comment enumerating ErrCorruptArchive use-cases still lists "corrupt PDF extraction." Corrupt PDFs now return ErrNoCover, not ErrCorruptArchive. Both sentinels remain in isPermanentCoverErr so the behavior is correct; only the comment is stale. Worth a follow-up to keep the comment accurate.


REVIEW VERDICT: 0 blocker, 0 major, 3 minor

## CODE REVIEW: APPROVED — bookshelf-uamr ### Phase 0: DEMO Verification No runnable DEMO block exists in the bead or PR body. This is a background-worker change (PDF cover extraction) with no interactive user surface to capture in a command DEMO. CI is green and the test plan is represented by the Ginkgo suite. The orchestrator has pre-cleared this; review proceeds to Phase 1. Note: the PR body contains a stale statement — "4 infrastructure/dead-code paths excluded in `check-coverage.sh` with justification." The actual branch has **zero** net changes to `scripts/check-coverage.sh` (confirmed by empty diff). The seam-based tests replaced those exclusions. The PR body was not updated after the revision. No code impact — pure documentation drift. --- ### Phase 1: Spec Compliance All five review criteria verified against the diff: 1. **Page-0 render correctness** (`cover_extract.go:483-484`): renders `Index: 0` bounded by `Width: maxPDFRenderWidth` (1200), `Height: maxPDFRenderHeight` (1800). go-pdfium documents these as "The maximum width/height" — aspect ratio is preserved, so a wide page caps at 1200 px and a tall page caps at 1800 px. Both constants are documented with rationale. The existing `maxPDFBytes` (200 MB) size guard fires before pdfium is invoked. ✓ 2. **Permanent error classification** (`cover_extract.go:469-496`): corrupt/password-protected PDF → `errors.Join(err, ErrNoCover)` (permanent sentinel); zero-page PDF → bare `ErrNoCover`; render failure → `errors.Join(err, ErrNoCover)`; PNG encode failure → `errors.Join(encErr, ErrNoCover)`. All four path-end errors wrap `ErrNoCover`, which is already in `wfengine.isPermanentCoverErr` at `cover_workflow.go:112`. Pool-init and GetInstance failures do **not** wrap ErrNoCover — correctly treated as transient infrastructure failures. No `go-workflows` imports anywhere in `internal/files/`. ✓ 3. **pdfRenderSeams DI** (`cover_extract.go:62-74`): package-level struct; production defaults are bound to real pdfium/PNG functions; seams overridden only via `cover_extract_export_test.go` (sanctioned `package files` export file). All 8 error-branch `It` blocks make real assertions — pool failure asserts `err` occurred and `result` is nil; instance failure same; render failure asserts `errors.Is(err, files.ErrNoCover)` and nil bytes; PNG encode failure same. No assert-nothing tests. ✓ 4. **Pool init is sync.Once, concurrency-safe** (`cover_extract.go:53-54, 96`): `pdfiumPoolOnce sync.Once` guards `webassembly.Init` exactly once. `MaxTotal: 1` serialises concurrent callers via `GetInstance(pdfiumInstanceTimeout)` (30 s timeout). Safe for the cover-generate worker. ✓ 5. **check-coverage.sh: zero new exclusions** — confirmed by empty diff. 100% coverage is achieved via the injectable seams. No new `golangci.yml` exclusions. ✓ --- ### Phase 2: Code Quality **No blockers. No majors. Three minors.** [MINOR] `internal/files/cover_extract.go` — `extractPDFCover` (line ~448) is a one-line passthrough that just calls `renderPDFCover`. It adds an indirection layer with no caller distinction. Could be collapsed, but it's harmless. [MINOR] `internal/files/cover_extract.go:99` — Comment on `getPdfiumPool` says "Pool init failure is a transient condition...that may be retried." The `sync.Once` caches the failure for the process lifetime, so within a single process the failure is effectively permanent (wfengine burns MaxAttempts on fast-returning cached errors before giving up). The comment is accurate only at the process-restart level. No correctness bug — eventual failure is the correct outcome — but the wording could mislead a reader into expecting in-process retry to succeed. [MINOR] `internal/wfengine/cover_workflow.go:96` (on main, not this diff) — The comment enumerating `ErrCorruptArchive` use-cases still lists "corrupt PDF extraction." Corrupt PDFs now return `ErrNoCover`, not `ErrCorruptArchive`. Both sentinels remain in `isPermanentCoverErr` so the behavior is correct; only the comment is stale. Worth a follow-up to keep the comment accurate. --- REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Author
Owner

Security Review — bookshelf-uamr (PR #971)

Reviewed: git diff origin/main...origin/bd-bookshelf-uamr
Focus areas: decompression-bomb/OOM, WASM sandbox bounds, permanent-error classification, supply chain, path traversal.


[MAJOR] internal/files/cover_extract.go:218-227 — pdfium.wasm declares no max memory; no wazero cap configured
The embedded pdfium.wasm binary's memory section specifies only a minimum (0 pages) with no maximum — confirmed by inspecting the WASM binary. wazero allows unbounded growth up to the 32-bit address limit (~4 GB). The webassembly.Init call passes no RuntimeConfig, so the default wazero.NewRuntimeConfig() is used with no memory ceiling. The 200 MB maxPDFBytes input guard caps the compressed PDF bytes handed to pdfium, but pdfium internally decompresses stream objects (flate/LZW/JBIG2/etc.) before rasterising. A crafted 200 MB PDF with deeply compressed streams can expand to gigabytes of WASM linear memory on the host, OOMing the worker process. Fix: pass RuntimeConfig: wazero.NewRuntimeConfig().WithMemoryLimitPages(N) (wazero supports this via the module-override path), or add a hard note that the worker container must be run under a cgroup memory limit that covers the expected WASM expansion headroom. At minimum, the current behaviour must be documented.

[MAJOR] internal/files/cover_extract.go:218-227 — Default FSConfig mounts host root filesystem read-write into pdfium WASM module
The PR passes no FSConfig in webassembly.Config. go-pdfium's Init then defaults to wazero.NewFSConfig().WithDirMount("/", "/") on Linux (source: go-pdfium@v1.19.4 webassembly/webassembly.go:94-95). WithDirMount provides read AND write access via WASI. pdfium only requires read access to system font directories and colour profiles for page rendering — no write access is needed. If a crafted PDF triggered a pdfium path that exercised WASI filesystem write calls (including any future pdfium regression), the WASM module would have write access to the entire host root. Fix: explicitly set FSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/") (wazero's WithReadOnlyDirMount), or restrict to font directories only (e.g., /usr/share/fonts, /etc/fonts).

[MAJOR] internal/files/cover_extract.go:350-358 — RenderPageInPixels has no deadline; a hanging PDF permanently blocks the MaxTotal=1 pool
pdfiumInstanceTimeout (30 s) guards only pool acquisition — the time waiting for a free instance. The RenderPageInPixels call itself has no context or deadline. A crafted PDF that causes pdfium to spin or block inside its WASM execution will hold the single pool instance (MaxTotal: 1) indefinitely, preventing all subsequent cover extractions. go-pdfium documents a mechanism to interrupt in-flight WASM execution: passing RuntimeConfig: wazero.NewRuntimeConfig().WithCloseOnContextDone(true) makes inst.Close() cancel the running WASM goroutine via context cancellation (go-pdfium@v1.19.4 webassembly/webassembly.go:379-381). Without this flag, inst.Close() is a no-op on a running WASM execution. Fix: add RuntimeConfig: wazero.NewRuntimeConfig().WithCloseOnContextDone(true) and wrap the render call with a context that has a finite deadline (e.g. 60 s), so a hanging render can be interrupted and the pool instance returned.

[MINOR] internal/files/cover_extract_export_test.go:1 — Declares package files (white-box) and references unexported pdfRenderSeams
The file follows the established export_test.go convention already present in the package (export_test.go also declares package files and exposes unexported symbols). Per project convention every *_test.go should be package files_test; export-bridge files are a controlled exception but still technically white-box. The seams are used by external (package files_test) tests that exercise the public ExtractCover API — so the test intent is correct. No immediate correctness or security impact.


Positive findings (confirmed safe):

  • maxPDFBytes (200 MB) input cap: retained and still checked before any PDF content is passed to pdfium.
  • Render output bounded: maxPDFRenderWidth = 1200, maxPDFRenderHeight = 1800 (≈8.6 MB RGBA cap on raster output).
  • Permanent-error classification: corrupt PDF (OpenDocument failure) and zero-page PDF both wrap ErrNoCover; isPermanentCoverErr in the wfengine maps ErrNoCover to gowf.NewPermanentError, preventing retry storms on poison PDFs. Pool-exhaustion and pool-init errors do NOT wrap ErrNoCover, correctly leaving them retryable.
  • No path traversal: PDF bytes passed by pointer (&pdfData) directly to pdfium; no path construction from PDF content.
  • No secret/PII logging in the new code paths.
  • Supply chain: github.com/klippa-app/go-pdfium v1.19.4 and github.com/jolestar/go-commons-pool/v2 v2.1.2 are established, well-known packages; go.sum hashes are present. The embedded pdfium.wasm (5 MB binary) is the standard klippa-app build — expected and acceptable.

REVIEW VERDICT: 0 blocker, 3 major, 1 minor

## Security Review — bookshelf-uamr (PR #971) Reviewed: `git diff origin/main...origin/bd-bookshelf-uamr` Focus areas: decompression-bomb/OOM, WASM sandbox bounds, permanent-error classification, supply chain, path traversal. --- [MAJOR] internal/files/cover_extract.go:218-227 — pdfium.wasm declares no max memory; no wazero cap configured The embedded `pdfium.wasm` binary's `memory` section specifies only a minimum (0 pages) with no maximum — confirmed by inspecting the WASM binary. wazero allows unbounded growth up to the 32-bit address limit (~4 GB). The `webassembly.Init` call passes no `RuntimeConfig`, so the default `wazero.NewRuntimeConfig()` is used with no memory ceiling. The 200 MB `maxPDFBytes` input guard caps the compressed PDF bytes handed to pdfium, but pdfium internally decompresses stream objects (flate/LZW/JBIG2/etc.) before rasterising. A crafted 200 MB PDF with deeply compressed streams can expand to gigabytes of WASM linear memory on the host, OOMing the worker process. Fix: pass `RuntimeConfig: wazero.NewRuntimeConfig().WithMemoryLimitPages(N)` (wazero supports this via the module-override path), or add a hard note that the worker container must be run under a cgroup memory limit that covers the expected WASM expansion headroom. At minimum, the current behaviour must be documented. [MAJOR] internal/files/cover_extract.go:218-227 — Default FSConfig mounts host root filesystem read-write into pdfium WASM module The PR passes no `FSConfig` in `webassembly.Config`. go-pdfium's `Init` then defaults to `wazero.NewFSConfig().WithDirMount("/", "/")` on Linux (source: go-pdfium@v1.19.4 webassembly/webassembly.go:94-95). `WithDirMount` provides read AND write access via WASI. pdfium only requires read access to system font directories and colour profiles for page rendering — no write access is needed. If a crafted PDF triggered a pdfium path that exercised WASI filesystem write calls (including any future pdfium regression), the WASM module would have write access to the entire host root. Fix: explicitly set `FSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/")` (wazero's `WithReadOnlyDirMount`), or restrict to font directories only (e.g., `/usr/share/fonts`, `/etc/fonts`). [MAJOR] internal/files/cover_extract.go:350-358 — RenderPageInPixels has no deadline; a hanging PDF permanently blocks the MaxTotal=1 pool `pdfiumInstanceTimeout` (30 s) guards only pool *acquisition* — the time waiting for a free instance. The `RenderPageInPixels` call itself has no context or deadline. A crafted PDF that causes pdfium to spin or block inside its WASM execution will hold the single pool instance (MaxTotal: 1) indefinitely, preventing all subsequent cover extractions. go-pdfium documents a mechanism to interrupt in-flight WASM execution: passing `RuntimeConfig: wazero.NewRuntimeConfig().WithCloseOnContextDone(true)` makes `inst.Close()` cancel the running WASM goroutine via context cancellation (go-pdfium@v1.19.4 webassembly/webassembly.go:379-381). Without this flag, `inst.Close()` is a no-op on a running WASM execution. Fix: add `RuntimeConfig: wazero.NewRuntimeConfig().WithCloseOnContextDone(true)` and wrap the render call with a context that has a finite deadline (e.g. 60 s), so a hanging render can be interrupted and the pool instance returned. [MINOR] internal/files/cover_extract_export_test.go:1 — Declares `package files` (white-box) and references unexported `pdfRenderSeams` The file follows the established `export_test.go` convention already present in the package (`export_test.go` also declares `package files` and exposes unexported symbols). Per project convention every `*_test.go` should be `package files_test`; export-bridge files are a controlled exception but still technically white-box. The seams are used by external (`package files_test`) tests that exercise the public `ExtractCover` API — so the test intent is correct. No immediate correctness or security impact. --- **Positive findings (confirmed safe):** - `maxPDFBytes` (200 MB) input cap: retained and still checked before any PDF content is passed to pdfium. - Render output bounded: `maxPDFRenderWidth = 1200`, `maxPDFRenderHeight = 1800` (≈8.6 MB RGBA cap on raster output). - Permanent-error classification: corrupt PDF (`OpenDocument` failure) and zero-page PDF both wrap `ErrNoCover`; `isPermanentCoverErr` in the wfengine maps `ErrNoCover` to `gowf.NewPermanentError`, preventing retry storms on poison PDFs. Pool-exhaustion and pool-init errors do NOT wrap `ErrNoCover`, correctly leaving them retryable. - No path traversal: PDF bytes passed by pointer (`&pdfData`) directly to pdfium; no path construction from PDF content. - No secret/PII logging in the new code paths. - Supply chain: `github.com/klippa-app/go-pdfium v1.19.4` and `github.com/jolestar/go-commons-pool/v2 v2.1.2` are established, well-known packages; go.sum hashes are present. The embedded `pdfium.wasm` (5 MB binary) is the standard klippa-app build — expected and acceptable. --- REVIEW VERDICT: 0 blocker, 3 major, 1 minor
fix(uamr): harden go-pdfium WASM with memory cap, read-only FS, and render deadline
All checks were successful
/ JS Unit Tests (pull_request) Successful in 39s
/ E2E API (pull_request) Successful in 2m8s
/ E2E Browser (pull_request) Successful in 2m30s
/ Lint (pull_request) Successful in 2m54s
/ Integration (pull_request) Successful in 3m4s
/ Test (pull_request) Successful in 3m56s
d20053e1ee
Security: closes the three [MAJOR] findings from the security review of the
go-pdfium WASM setup (internal/files/cover_extract.go):

1. WASM memory cap (pdfiumMaxMemoryPages = 16384 = ~1 GB): passes a bounded
   RuntimeConfig.WithMemoryLimitPages to webassembly.Init, preventing a
   crafted PDF from growing WASM linear memory to ~4 GB and OOMing the worker.

2. Read-only FS: passes FSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/")
   so pdfium page rendering has no write access to the host filesystem from WASM.

3. Render deadline + interruptible execution: adds RuntimeConfig.WithCloseOnContextDone(true)
   so inst.Close() interrupts in-flight WASM, and wraps the RenderPageInPixels call
   in a 60s context.WithTimeout goroutine. On timeout, closeInstance() is called
   (interrupting WASM), the goroutine is drained, and ErrNoCover is returned — the
   pool slot is always released, preventing permanent DoS of the MaxTotal=1 pool.

Also fixes the sync.Once comment (init failure is cached permanently, not retryable)
and adds injectable seams + tests for the deadline path:
- SetPdfGetNilInstanceForTest / SetPdfSimpleRenderPage0ForTest /
  SetPdfSimpleCloseInstanceForTest / SetPdfRenderTimeoutForTest (export_test.go)
- "PDF render: deadline exceeded" — verifies timeout error wraps ErrNoCover and
  closeInstance is called
- "PDF render: subsequent render succeeds after deadline" — verifies pool is not
  permanently exhausted after a timed-out render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

CODE REVIEW (focused re-review): render-deadline / pool-release concurrency — bookshelf-uamr

Reviewed diff: origin/main...origin/bd-bookshelf-uamr — files examined:

  • internal/files/cover_extract.go (production concurrency/deadline logic)
  • internal/files/cover_extract_export_test.go (seam setters)
  • internal/files/cover_extract_test.go (deadline tests)
  • .golangci.yml / scripts/check-coverage.sh (exclusion audit)

Checklist results

1. cancel() always called — PASS
cover_extract.go:511: defer cancel() is placed immediately after context.WithTimeout(...). Deferred unconditionally; fires on both success and timeout paths.

2. No goroutine leak — PASS
cover_extract.go:517: channel is make(chan renderResult, 1) — buffered capacity 1. The goroutine can always send without blocking, regardless of which branch the select took. On the timeout path, <-ch at line 532 explicitly drains the goroutine before renderPDFCover returns, so no goroutine is orphaned even in the adversarial case.

3. Pool instance released on both paths — PASS

  • Success path (cover_extract.go:525): pdfRenderSeams.closeInstance(inst) called before returning.
  • Timeout path (cover_extract.go:531): pdfRenderSeams.closeInstance(inst) called, then <-ch drains the goroutine, then returns. The MaxTotal=1 pool slot is released in both cases via the closeInstance seam (which in production calls inst.Close(), releasing the WASM worker back to the pool via workerPool.InvalidateObject — enabled by the WithCloseOnContextDone(true) pool config at line 145).

4. ErrNoCover returned on timeout — PASS
cover_extract.go:533: return nil, fmt.Errorf("pdf: render: deadline exceeded: %w", ErrNoCover). A hanging PDF is classified as bad-input / PERMANENT — correct.

5. Tests: timeout path is deterministic — PASS
SetPdfRenderTimeoutForTest(time.Millisecond) injects a 1 ms deadline; no real 60-second sleep. The goroutine stub blocks on <-unblockRender; the closeInstance stub closes unblockRender, so the goroutine is guaranteed to unblock. Fully deterministic; no wall-clock flake.

6. Tests: closeInstance called assertion — PASS
cover_extract_test.go "PDF render: deadline exceeded" — closeInstanceCalled flag set inside stub, asserted in It("calls closeInstance to release the pool slot").

7. Tests: subsequent render succeeds after deadline — PASS
"PDF render: subsequent render succeeds after deadline" — callN atomic counter differentiates first (blocking) from second (immediate-return) invocation. After first timeout, closeOnce.Do extends the timeout to 1 second and closes unblockRender, allowing the goroutine to drain. Second call proceeds with extended timeout and the stub returns immediately. Both assertions (secondResultErr is nil, secondResult non-empty) prove the pool-slot mechanism works end-to-end.

8. No .golangci.yml or scripts/check-coverage.sh exclusions added — PASS
Zero diff on both files; confirmed by git diff --name-only.

9. Test packages are black-box — PASS
cover_extract_test.go is package files_test; cover_extract_export_test.go is package files (sanctioned export seam file — correct use of the pattern).


Findings

[MINOR] internal/files/cover_extract.go:531 — second //nolint:errcheck lacks a justification comment
The timeout-path closeInstance nolint on line 531 has no inline rationale, while the success-path nolint on line 525 carries // pool-instance close; errors are non-actionable. Inconsistent; a reviewer has to infer the reason. Add the same comment: // pool-instance close; errors are non-actionable.

[MINOR] internal/files/cover_extract_test.go "PDF render: subsequent render succeeds after deadline" BeforeEachSetPdfRenderTimeoutForTest return value discarded inside stub
Inside the closeOnce.Do(func() { files.SetPdfRenderTimeoutForTest(time.Second); ... }) block, the returned restore function is silently discarded. The outer DeferCleanup correctly handles final cleanup so this is not a bug, but discarding a restore-func without a comment is surprising to readers. A one-line comment (// restore handled by outer DeferCleanup) would make the intent explicit.


REVIEW VERDICT: 0 blocker, 0 major, 2 minor

CODE REVIEW (focused re-review): render-deadline / pool-release concurrency — bookshelf-uamr Reviewed diff: `origin/main...origin/bd-bookshelf-uamr` — files examined: - `internal/files/cover_extract.go` (production concurrency/deadline logic) - `internal/files/cover_extract_export_test.go` (seam setters) - `internal/files/cover_extract_test.go` (deadline tests) - `.golangci.yml` / `scripts/check-coverage.sh` (exclusion audit) --- ## Checklist results **1. `cancel()` always called — PASS** `cover_extract.go:511`: `defer cancel()` is placed immediately after `context.WithTimeout(...)`. Deferred unconditionally; fires on both success and timeout paths. **2. No goroutine leak — PASS** `cover_extract.go:517`: channel is `make(chan renderResult, 1)` — buffered capacity 1. The goroutine can always send without blocking, regardless of which branch the `select` took. On the timeout path, `<-ch` at line 532 explicitly drains the goroutine before `renderPDFCover` returns, so no goroutine is orphaned even in the adversarial case. **3. Pool instance released on both paths — PASS** - Success path (`cover_extract.go:525`): `pdfRenderSeams.closeInstance(inst)` called before returning. - Timeout path (`cover_extract.go:531`): `pdfRenderSeams.closeInstance(inst)` called, then `<-ch` drains the goroutine, then returns. The MaxTotal=1 pool slot is released in both cases via the `closeInstance` seam (which in production calls `inst.Close()`, releasing the WASM worker back to the pool via `workerPool.InvalidateObject` — enabled by the `WithCloseOnContextDone(true)` pool config at line 145). **4. ErrNoCover returned on timeout — PASS** `cover_extract.go:533`: `return nil, fmt.Errorf("pdf: render: deadline exceeded: %w", ErrNoCover)`. A hanging PDF is classified as bad-input / PERMANENT — correct. **5. Tests: timeout path is deterministic — PASS** `SetPdfRenderTimeoutForTest(time.Millisecond)` injects a 1 ms deadline; no real 60-second sleep. The goroutine stub blocks on `<-unblockRender`; the `closeInstance` stub closes `unblockRender`, so the goroutine is guaranteed to unblock. Fully deterministic; no wall-clock flake. **6. Tests: `closeInstance` called assertion — PASS** `cover_extract_test.go` "PDF render: deadline exceeded" — `closeInstanceCalled` flag set inside stub, asserted in `It("calls closeInstance to release the pool slot")`. **7. Tests: subsequent render succeeds after deadline — PASS** "PDF render: subsequent render succeeds after deadline" — `callN` atomic counter differentiates first (blocking) from second (immediate-return) invocation. After first timeout, `closeOnce.Do` extends the timeout to 1 second and closes `unblockRender`, allowing the goroutine to drain. Second call proceeds with extended timeout and the stub returns immediately. Both assertions (`secondResultErr` is nil, `secondResult` non-empty) prove the pool-slot mechanism works end-to-end. **8. No `.golangci.yml` or `scripts/check-coverage.sh` exclusions added — PASS** Zero diff on both files; confirmed by `git diff --name-only`. **9. Test packages are black-box — PASS** `cover_extract_test.go` is `package files_test`; `cover_extract_export_test.go` is `package files` (sanctioned export seam file — correct use of the pattern). --- ## Findings [MINOR] `internal/files/cover_extract.go:531` — second `//nolint:errcheck` lacks a justification comment The timeout-path `closeInstance` nolint on line 531 has no inline rationale, while the success-path nolint on line 525 carries `// pool-instance close; errors are non-actionable`. Inconsistent; a reviewer has to infer the reason. Add the same comment: `// pool-instance close; errors are non-actionable`. [MINOR] `internal/files/cover_extract_test.go` "PDF render: subsequent render succeeds after deadline" `BeforeEach` — `SetPdfRenderTimeoutForTest` return value discarded inside stub Inside the `closeOnce.Do(func() { files.SetPdfRenderTimeoutForTest(time.Second); ... })` block, the returned restore function is silently discarded. The outer `DeferCleanup` correctly handles final cleanup so this is not a bug, but discarding a restore-func without a comment is surprising to readers. A one-line comment (`// restore handled by outer DeferCleanup`) would make the intent explicit. --- REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

Security Re-Review — PR #971 (bookshelf-uamr)

Focused re-check of the three prior [MAJOR] findings on the go-pdfium/WASM PDF cover render path in internal/files/cover_extract.go.


Prior MAJOR #1 — No WASM memory cap (OOM risk)

Status: RESOLVED

getPdfiumPool now passes:

RuntimeConfig: wazero.NewRuntimeConfig().
    WithMemoryLimitPages(pdfiumMaxMemoryPages).   // 16384 × 64 KB = 1 GB
    WithCloseOnContextDone(true),

pdfiumMaxMemoryPages = 16384 caps wazero linear memory to exactly 1 GB. A 1200×1800 RGBA frame is ~8.6 MB so the cap is well above any real render and well below what would OOM a worker. The maxPDFBytes = 200 MB input cap is still present at ExtractCoverFromFile before io.ReadAll, providing a second boundary. No concern.


Prior MAJOR #2 — Host / mounted read-write into WASM

Status: RESOLVED

FSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/") is the only mount call in the file (verified: grep WithReadOnlyDirMount\|WithReadWrite\|WithDir returns exactly one line — line 138). WASM has read-only access to the host filesystem; no write path from WASM to the host remains.


Prior MAJOR #3 — Render has no deadline; hanging PDF permanently exhausts the MaxTotal=1 pool

Status: RESOLVED

renderPDFCover now:

  1. Opens a context.WithTimeout(context.Background(), pdfRenderTimeout) (60 s default).
  2. Runs the render in a goroutine sending on a buffered ch (capacity 1 — goroutine can always send without blocking).
  3. On timeout (<-ctx.Done()): calls closeInstance(inst) — which, with WithCloseOnContextDone(true), cancels the wazero worker context and interrupts in-flight WASM execution — then drains <-ch to prevent goroutine leak.
  4. On success: calls closeInstance(inst) to return the slot to the pool before returning.

Pool slot release is verified: no double-close is possible (each select arm calls closeInstance exactly once; the goroutine never calls it). The test "subsequent render succeeds after deadline" drives the timeout path and asserts the second render completes, confirming no permanent pool exhaustion.

Permanent error classification via ErrNoCover wrapping:

  • Corrupt/unrenderable PDF (OpenDocument failure): wraps ErrNoCover — PERMANENT.
  • Zero-page PDF: returns ErrNoCover directly — PERMANENT.
  • Render failure: wraps ErrNoCover — PERMANENT.
  • PNG-encode failure: wraps ErrNoCover — PERMANENT.
  • Deadline exceeded: wraps ErrNoCover — PERMANENT (correct: adversarial hangers get no retry).
  • Pool-init failure / GetInstance timeout: NOT wrapped in ErrNoCover — treated as transient (correct: infrastructure-level, not file-level; isPermanentCoverErr in wfengine won't match, so go-workflows retries).

The wfengine isPermanentCoverErr already lists ErrNoCover and the domain package does not import the workflow engine — architecture boundary intact.


Additional observations (not regressions, not blockers)

[MINOR] internal/files/cover_extract.go:88 — init() used to assign pdfRenderPage0Fn
Project convention: "no side-effecting init". This init() only assigns a function closure (no I/O, no external state), but the convention is stated broadly. The comment explains the necessity (breaking a var→func→var initialization cycle). A non-init alternative (lazy nil-check inside renderPDFCover) would satisfy the convention. Not a security issue.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Re-Review — PR #971 (bookshelf-uamr) Focused re-check of the three prior [MAJOR] findings on the go-pdfium/WASM PDF cover render path in `internal/files/cover_extract.go`. --- ### Prior MAJOR #1 — No WASM memory cap (OOM risk) **Status: RESOLVED** `getPdfiumPool` now passes: ```go RuntimeConfig: wazero.NewRuntimeConfig(). WithMemoryLimitPages(pdfiumMaxMemoryPages). // 16384 × 64 KB = 1 GB WithCloseOnContextDone(true), ``` `pdfiumMaxMemoryPages = 16384` caps wazero linear memory to exactly 1 GB. A 1200×1800 RGBA frame is ~8.6 MB so the cap is well above any real render and well below what would OOM a worker. The `maxPDFBytes = 200 MB` input cap is still present at `ExtractCoverFromFile` before `io.ReadAll`, providing a second boundary. No concern. --- ### Prior MAJOR #2 — Host `/` mounted read-write into WASM **Status: RESOLVED** `FSConfig: wazero.NewFSConfig().WithReadOnlyDirMount("/", "/")` is the only mount call in the file (verified: `grep WithReadOnlyDirMount\|WithReadWrite\|WithDir` returns exactly one line — line 138). WASM has read-only access to the host filesystem; no write path from WASM to the host remains. --- ### Prior MAJOR #3 — Render has no deadline; hanging PDF permanently exhausts the MaxTotal=1 pool **Status: RESOLVED** `renderPDFCover` now: 1. Opens a `context.WithTimeout(context.Background(), pdfRenderTimeout)` (60 s default). 2. Runs the render in a goroutine sending on a buffered `ch` (capacity 1 — goroutine can always send without blocking). 3. On timeout (`<-ctx.Done()`): calls `closeInstance(inst)` — which, with `WithCloseOnContextDone(true)`, cancels the wazero worker context and interrupts in-flight WASM execution — then drains `<-ch` to prevent goroutine leak. 4. On success: calls `closeInstance(inst)` to return the slot to the pool before returning. Pool slot release is verified: no double-close is possible (each select arm calls `closeInstance` exactly once; the goroutine never calls it). The test "subsequent render succeeds after deadline" drives the timeout path and asserts the second render completes, confirming no permanent pool exhaustion. Permanent error classification via `ErrNoCover` wrapping: - Corrupt/unrenderable PDF (`OpenDocument` failure): wraps `ErrNoCover` — PERMANENT. - Zero-page PDF: returns `ErrNoCover` directly — PERMANENT. - Render failure: wraps `ErrNoCover` — PERMANENT. - PNG-encode failure: wraps `ErrNoCover` — PERMANENT. - Deadline exceeded: wraps `ErrNoCover` — PERMANENT (correct: adversarial hangers get no retry). - Pool-init failure / GetInstance timeout: NOT wrapped in `ErrNoCover` — treated as transient (correct: infrastructure-level, not file-level; `isPermanentCoverErr` in `wfengine` won't match, so go-workflows retries). The `wfengine` `isPermanentCoverErr` already lists `ErrNoCover` and the domain package does not import the workflow engine — architecture boundary intact. --- ### Additional observations (not regressions, not blockers) [MINOR] internal/files/cover_extract.go:88 — `init()` used to assign `pdfRenderPage0Fn` Project convention: "no side-effecting init". This `init()` only assigns a function closure (no I/O, no external state), but the convention is stated broadly. The comment explains the necessity (breaking a var→func→var initialization cycle). A non-init alternative (lazy nil-check inside `renderPDFCover`) would satisfy the convention. Not a security issue. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
zombor force-pushed bd-bookshelf-uamr from d20053e1ee
All checks were successful
/ JS Unit Tests (pull_request) Successful in 39s
/ E2E API (pull_request) Successful in 2m8s
/ E2E Browser (pull_request) Successful in 2m30s
/ Lint (pull_request) Successful in 2m54s
/ Integration (pull_request) Successful in 3m4s
/ Test (pull_request) Successful in 3m56s
to d195b5a702
All checks were successful
/ JS Unit Tests (pull_request) Successful in 32s
/ E2E API (pull_request) Successful in 2m49s
/ Integration (pull_request) Successful in 3m50s
/ Lint (pull_request) Successful in 3m55s
/ E2E Browser (pull_request) Successful in 4m22s
/ Test (pull_request) Successful in 4m46s
2026-07-06 02:32:08 +00:00
Compare
zombor merged commit 6e50f252f9 into main 2026-07-06 02:45:00 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!971
No description provided.