Skip to content

Cleanup, a use-after-free in every PDF conversion, and named pdfium bindings - #9

Merged
lemcoder merged 9 commits into
mainfrom
cleanup
Aug 28, 2026
Merged

Cleanup, a use-after-free in every PDF conversion, and named pdfium bindings#9
lemcoder merged 9 commits into
mainfrom
cleanup

Conversation

@lemcoder

@lemcoder lemcoder commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Important

Four commits added after review. Two claims in the original description below did not survive them, and are corrected here.

Review fixes — 21c353f

Fourteen findings. The detector's ZIP branch had collapsed to a constant ("zip" was in the extension set but absent from the table, so only "epub" ever changed the answer) and the OLE signature went with the office cleanup, leaving legacy .doc/.xls/.ppt as No converter found for: unknown. Nothing exercised SignatureMimeDetector at all — the integration tests build their own StreamInfo and never reach detection — so 7 new tests cover it.

CI compiled neither the native target nor the pdfium bindings; there is now a macOS leg running :library:macosArm64Test :pdfium:jvmTest. ktfmt named the Android source sets explicitly and would have missed any target added later. The benchmark died on the second fixture, because the office files it cannot convert are still in the directory. The four testMikroMarkdown() actuals went, along with the expect — the factory is common now, so commonTest calls it directly.

Correction: the description below says the library has no third-party JVM dependency. commonMain declares three — kotlinx-io, Ksoup, korlibs-compression — and all three reach JVM consumers. What went was the JVM-only dependency, Tika. README now says that.

publish.yml is deleted: the publishing plugin has not been applied since 5113f53, so every release run could only fail, and README pointed at a Maven Central coordinate nothing publishes.

PDF wraps, from geometry — 7fb8260

Correction: the description below says no output changes, every fixture byte for byte. PDF output did change, and for the better. It could not be verified as stated, for the reason in the next section.

U+FFFE is not an unmapped glyph. FPDFText_HasUnicodeMapError is 0 at all eleven occurrences in the fixture and FPDFText_IsHyphen is 1: it marks a hyphen pdfium removed while joining a wrapped line. The halves arrive adjacent with nothing to say a line ended between them, which is why replace(UNMAPPED_GLYPH, "-\n") had to assume one — and why it fabricated breaks where there were none, leaked a stray - for any other undecodable glyph, and swallowed the following paragraph when a marker landed at a paragraph end.

FPDFText_GetCharBox settles the one open question: every marker sits at the right margin with the next character on a lower line, so the discarded break is put back on evidence. A marker the boxes do not confirm now leaves nothing behind.

The geometry cannot say which hyphen it was — equally true of con-|firming and chat-|optimized — so nothing guesses. The vocabulary heuristic that used to is gone: it fused chat-optimized into chatoptimized, a word in no document anywhere. Keeping the hyphen writes con-firming where the page meant confirming, which reads slightly worse and destroys nothing. TextBlocks goes 113 → 71 lines, and the pdfium sentinel leaves model/, which ArchitectureTest pins as depending on nothing.

A use-after-free in every PDF conversion — a3b45e7

The reason "byte for byte" could not be verified. FPDF_LoadMemDocument reads the caller's buffer for as long as the document is open; the generated JNI bridge releases it before returning:

jbyte* p0 = (*jniEnv)->GetByteArrayElements(jniEnv, j0, 0);
jlong kniResult = (jlong)FPDF_LoadMemDocument((void*)p0, p1, ...);
(*jniEnv)->ReleaseByteArrayElements(jniEnv, j0, p0, 0);
return kniResult;

Every page load afterwards read memory the JVM had taken back. Zeroing the array under an open document drops extraction to 0 characters, so pdfium was reading our array directly, not a copy.

It mostly worked, which is the worst way to fail: the first conversion in a process was correct and later ones lost four generated spaces, turning AutoGen uses into AutoGenuses. One fixture, repeatedly, in a single process: 5220 / 5216 / 5220 / 5216, and a different sequence in the next process. Through FPDF_LoadDocument, which owns what it reads: 5220 every time, everywhere.

Costs 0.12 ms against 2.66 ms of parse for a 90 KB PDF. The native leg was never affected — usePinned holds the array for the document's lifetime — and is untouched. A regression test now converts the same bytes twice and asserts the documents match.

anydoc pinned to v0.1.8 — b3dc095

The comparison figures were measured against whatever the submodule happened to be at. v0.1.8 does not change the hyphen handling: eleven of the twelve word- word sites in its output of our fixture are still broken words, from an unconditional space in pdf-inspector's line join. Unreported upstream.


Everything below describes the original commit, d0843c7.

Cleanup only. No output changes — every fixture, PDF included, renders byte for byte what it rendered on main, verified through the native CLI before and after.

517 lines deleted, 190 added.

Dead code

The builder DSL, 160 lines, zero users. DocumentBuilder/InlineBuilder/styled were written for the office converters, which built documents statement by statement. What remains constructs Document directly or comes out of HtmlToDocument; the only mention left in the repo was the README example. Two secondary constructors — Heading(level, text) and Paragraph(text) — cover what the DSL was actually pleasant for:

Document(blocks = listOf(Heading(1, "Custom"), Paragraph(bytes.decodeToString())))

Three copies of the factory. JVM, Android and macOS each registered the same seven converters, which is what a registration list looks like once every converter is common. Now one, in commonMain. The convert(file: File) overloads two of them carried had no callers at all — the JVM tests and the benchmark have always used convert(file.absolutePath).

TikaMimeDetector. Unreferenced, untested, and it put tika-core on every JVM consumer's classpath to provide an implementation of a fun interface whose entire purpose is that a caller supplies their own. The library now has no third-party JVM dependency and no jvmMain source set — production code is entirely common.

Also: HtmlComment (nothing produced it once the builder went, and RawBlock covers it); docx/xlsx/pptx and the OLE signature in the mime detector, for formats deliberately dropped; four libraries and two plugins left in the version catalog by POI, Jackson, commons-csv and kotlinx-serialization; .codegraph/.

One heuristic instead of two

PdfiumConverter decided whether a U+FFFE was the author's hyphen or the typesetter's. plainTextBlocks decided the same thing for a hyphen at a line break. Two vocabularies, two copies of Regex("[\\p{L}]{2,}"), one question. Since the unmapped glyph is a hyphen at a line break, spelling it as one leaves a single rule:

.replace(UNMAPPED_GLYPH, "-\n")

and PdfiumConverter drops from 76 lines to 29.

That also fixed the copy that was wrong. plainTextBlocks built its vocabulary from the whole text, so flexibil- broken across a line entered the vocabulary as a word and then vouched for itself, keeping a hyphen it should have dropped. pdfium's copy cut the halves out first, which is why nothing was visibly broken — and why no output moves now that the correct rule is the only one.

Two things it turned up

Konsist's duplicate-file allowlist named a file that no longer exists. The rule against one production file living in two source sets was excepting MikroMarkdownFactory and PdfConverter; the second went with the office removal, the first goes here. The allowlist is empty and the rule is now absolute.

The native target's tests were switched off. The reason recorded in library/build.gradle.kts was that the shared integration tests expected JVM-only formats — untrue since the converters moved to commonMain. Re-enabled:

> Task :library:macosArm64Test
  officeFormatsAreNotSupported[macosArm64]  testBlogHtml[macosArm64]
  testWikipediaHtml[macosArm64]  testJson[macosArm64]  testEpub[macosArm64]

API removals

Pre-1.0, and all of them things nothing used: the builder DSL, convert(File), TikaMimeDetector, HtmlComment. A caller who wants Tika-grade sniffing passes a MimeDetector — the README now says so, and shows it.

./gradlew check passes.

🤖 Generated with Claude Code

lemcoder and others added 6 commits August 19, 2026 13:56
Nothing here changes output. Every fixture, PDF included, renders byte for byte what
it rendered before.

The builder DSL had no users. It was written for the office converters, which built
documents statement by statement; the converters that remain construct Document
directly or come out of HtmlToDocument, and the only mention left was the README. A
Heading(level, text) and Paragraph(text) constructor cover what the DSL was pleasant
for, in four lines rather than a hundred and sixty of public API.

There were three copies of the factory — JVM, Android, macOS — registering the same
seven converters, which is what a converter list looks like once every converter is
common. One copy in commonMain. The File overloads they carried went with them:
nothing called either, not even the JVM tests, which had always used
convert(file.absolutePath).

TikaMimeDetector was unreferenced and untested, and it put tika-core on the
classpath of every JVM consumer to provide an implementation of a fun interface
whose whole purpose is that a caller can supply their own. With it gone the library
has no third-party JVM dependency at all, and no jvmMain source set — production
code is entirely common.

The de-hyphenation heuristic existed twice. PdfiumConverter decided whether U+FFFE
was the author's hyphen or the typesetter's, and plainTextBlocks decided the same
thing for a hyphen at a line break, each with its own vocabulary and its own copy of
the word regex. Since the unmapped glyph is a hyphen at a line break, writing it as
one leaves a single rule. That also fixed the copy that was wrong: plainTextBlocks
built its vocabulary from the whole text, so a fragment appearing only at the break
vouched for itself and kept a hyphen it should have dropped. Only pdfium calls it,
and pdfium's own copy did exclude the halves, so no output moves.

Also gone: HtmlComment, which nothing produced once the builder went and which
RawBlock already covers; docx/xlsx/pptx and OLE in the signature detector, formats
we deliberately dropped; four library entries and two plugins in the version catalog
left behind by POI, Jackson, commons-csv and kotlinx-serialization; and .codegraph,
which is no longer used.

Two things the cleanup turned up rather than removed. Konsist's rule against a
production file existing in two source sets had an allowlist naming
MikroMarkdownFactory and PdfConverter — the second has not existed since the office
removal. The allowlist is now empty and the rule absolute. And macosArm64's test
compilation had been switched off because the shared integration tests expected
JVM-only formats; they have not since the converters moved, so the native target
runs the same five tests as the JVM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The detector's ZIP branch had collapsed to a constant: "zip" was in the
extension set but absent from the table, so only "epub" ever changed the
answer and the takeIf/elvis around it decided nothing. The OLE signature
that names a legacy .doc/.xls/.ppt went with it, leaving those files as
"No converter found for: unknown". Neither was caught, because nothing
exercised SignatureMimeDetector at all — the integration tests hand
converters a StreamInfo they build themselves and never reach detection.

The expect/actual testMikroMarkdown() indirection outlived its reason when
the factory moved to commonMain: four identical actuals returning
MikroMarkdown(), which commonTest can call directly.

CI compiled neither the native target nor the pdfium bindings, so the
macosArm64 source set and every pdfium test could break unnoticed. ktfmt
named the Android source sets explicitly and so would miss any target
added later; it now covers everything under src/ that ktfmt does not
derive for itself.

The benchmark converted every fixture in the directory with no try/catch
while the directory still holds the office formats, so the documented
`./gradlew :benchmark:run` died on the second file.

README claimed no third-party JVM dependency at all, which three
implementation dependencies contradict, and pointed at a Maven Central
coordinate nothing publishes: the plugin has not been applied since
5113f53, so publish.yml could only ever fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
U+FFFE is not what the comments said. FPDFText_HasUnicodeMapError is 0 at
every one of the eleven occurrences in the AutoGen fixture and
FPDFText_IsHyphen is 1: the marker stands for a hyphen pdfium removed
while joining a wrapped line, not for a glyph it could not decode. So the
two halves arrive adjacent with nothing to say a line ever ended between
them, which is why the old rule had to guess.

FPDFText_GetCharBox answers the one question that is left. Every marker
sits at the right margin with the following character on a lower line, so
the break pdfium discarded can be put back — and a marker the boxes do not
confirm was some other undecodable character, which now leaves nothing
behind instead of a stray hyphen. That is the whole of what the geometry
settles: it is equally true of con-|firming and chat-|optimized, so it
cannot say which hyphen was the author's.

Nothing tries to. Weighing the halves against the rest of the document was
tried and cost more than it returned — a compound whose halves appear
nowhere else came out as "chatoptimized", a word in no document anywhere.
Keeping the hyphen writes "con-firming" where the page meant "confirming",
which reads a little worse and destroys nothing. That deletes the
vocabulary, the two regexes and the compound test that served it, and
takes TextBlocks from 113 lines to 71.

The pdfium sentinel leaves the model with it. model/ is pinned by
ArchitectureTest as depending on nothing, and it now knows only that a line
can end mid-word — in any of the three hyphens a document may write, since
:pdfium restores the one its own text uses rather than substituting U+002D.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FPDF_LoadMemDocument keeps the caller's pointer and reads through it for
as long as the document is open. The generated bridge does not:

    jbyte* p0 = (*jniEnv)->GetByteArrayElements(jniEnv, j0, 0);
    jlong kniResult = (jlong)FPDF_LoadMemDocument((void*)p0, p1, ...);
    (*jniEnv)->ReleaseByteArrayElements(jniEnv, j0, p0, 0);
    return kniResult;

It releases before returning, so every page load and text extraction
afterwards reads memory the JVM has taken back. Filling the array with
zeros under an open document drops extraction to nothing, and forcing
malloc to hand that block to something else does the same — pdfium was
reading our array directly, not a copy of it.

It mostly worked, which is the worst way to fail. The first conversion in
a process reads a region nothing has touched yet and is correct; later
ones read reused memory and come back four generated spaces short, so
"AutoGen uses" reads as "AutoGenuses". Repeated extraction of one fixture
gave 5220 / 5216 / 5220 / 5216 characters within a single process and a
different sequence in the next. Through FPDF_LoadDocument, which owns
everything it reads, it is 5220 every time in every process.

The temporary file costs 0.12 ms against 2.66 ms of parse for a 90 KB PDF.
It is created before the load and deleted only after FPDF_CloseDocument,
because pdfium reads a path as lazily as it reads a buffer. The native leg
needs none of this and is untouched: usePinned there holds the array for
the document's whole lifetime, which is why only the JVM legs were wrong.

Pairing FPDF_InitLibrary with FPDF_DestroyLibrary per call turned out to
make extraction differ between one process and the next, so the library is
initialised once and left for the process to reclaim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark and comparison figures are measured against whatever the
submodule happens to be checked out at, and nothing recorded which version
that was. v0.1.8 changes strikethrough and footnote markers in its PDF
snapshot; it does not change the hyphen handling the comparison turns on —
wrapped lines are still joined with an unconditional space, so eleven of
the twelve "word- word" sites in its output of our fixture are broken
words. That is pdf-inspector's doing rather than anydoc's, and it is
unreported upstream.

README now names both pinned versions and the fetch a fresh clone needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The macOS CI leg added a commit ago failed on a clean runner:

    Execution failed for task ':pdfium:generateJvmInteropPdfium'
    > No Kotlin/Native distribution found. The Kotlin plugin downloads one
      when it first compiles a native target, so build one of those first.

The generator reads that distribution, and the Kotlin plugin fetches it
only as a side effect of compiling a native target. Every machine that had
built one already — mine, and any working tree that had run the native
target once — hid it. It is not a CI problem: `./gradlew :pdfium:jvmTest`
in a fresh clone fails the same way, which is why the ordering goes in the
build rather than in the workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lemcoder and others added 2 commits August 20, 2026 01:38
`python3 scripts/benchmark.py`, as the README documents it, died on

    mikro = next(e for e in engines if e.name == "mikromarkdown")
    StopIteration

The engine was renamed to "native" when the JVM CLI went and the Kotlin/
Native binary replaced it. Two references were left behind, both there to
measure JVM startup and subtract it from the CLI's timing — a correction
with nothing left to correct, since the native binary starts no runtime.
The header line that reported the baseline goes with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:cli-native` named pdfium a second time — a `-L`, a `-lpdfium` and an
rpath repeated in the consumer, with a comment saying Kotlin/Native does
not carry a klib's linker options to the binary that uses it. It does,
through one channel only: `linkerOpts` in the cinterop `.def`, which
cinterop writes into the klib manifest and passes to whatever links the
binding.

Two things it will not do, both tried and both refused. `extraOpts
("-linker-option", ...)` earns "-linker-option(s)/-linkerOpts option is
not supported by cinterop. Please add linker options to .def file or
binary compilation instead." And `libraryPaths`, which the klib does
record, is cinterop's own search path: the consumer's linker never sees
it, and the link fails with "library 'pdfium' not found".

Nor can the paths be relative. kotlin-native#2314 reported that relative
paths in a .def resolve against the working directory the compiler runs
in rather than the file's own location, and that still holds on 2.3.21 —
the same .def indexes cleanly from its own directory and fails with
"'dummy.h' file not found" from the repository root. An rpath has to hold
at run time regardless of where the binary is started from, so it could
not be relative even if the def resolved sensibly.

That leaves absolute paths, and pdfium is unpacked under build/, so the
.def is written at build time from the checked-in one. The checked-in one
is unchanged: the JVM and Android legs bind it, and a macOS library path
has no business in it.

Static linking would be the better answer, and the mechanism is there —
`staticLibraries` packs the archive into the klib, verified against a
dummy library that lands at targets/<target>/included/libdummy.a, and a
consumer then needs no options at all. pdfium-binaries publishes no static
build, all 46 assets of chromium/8009 being shared libraries, so the 0.8 ms
of dyld work the 7.2 MB dylib costs stays where it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Konan plugin numbered the bridges, so this module imported
`kniBridge54` and wrapped it in `charCount(textPage)` to give it a name —
eleven such wrappers, existing only to undo the numbering. konanplugin
1.2.0-alpha07 names each binding after the C function it calls, so the
wrappers have nothing left to do and the code reads as pdfium's own API:

    val document = FPDF_LoadDocument(file.absolutePath, null)
    for (index in 0 until FPDF_GetPageCount(document)) {

That is 62 lines out of the two platform legs, and a stack frame or linker
error now names something that can be looked up in pdfium's headers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lemcoder lemcoder changed the title Delete what the earlier passes made redundant Cleanup, a use-after-free in every PDF conversion, and named pdfium bindings Aug 28, 2026
@lemcoder
lemcoder merged commit fa21580 into main Aug 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant