Skip to content

fix(native): stop exporting the symbols of statically linked dependencies - #39

Open
prakunin wants to merge 1 commit into
grimmory-tools:mainfrom
prakunin:fix/hide-vendored-symbols
Open

fix(native): stop exporting the symbols of statically linked dependencies#39
prakunin wants to merge 1 commit into
grimmory-tools:mainfrom
prakunin:fix/hide-vendored-symbols

Conversation

@prakunin

@prakunin prakunin commented Aug 4, 2026

Copy link
Copy Markdown

What

libepub4j_native statically links pugixml, gumbo, uchardet, zlib, libarchive, libjpeg-turbo, libpng and libwebp, and currently exports all of their symbols as global dynamic symbols. On linux-x86_64 that is 2023 exported symbols, of which 43 are the epub_native_* public API and 270 are libarchive's archive_* entry points.

This PR restricts the exported surface to the public C API. It changes no source, no header, no Java and no Gradle — only the link step.

Why it matters to consumers

Anything else in the same process can bind to epub4j's private copy of a library instead of its own. The concrete case: a JVM that resolves libarchive through SymbolLookup.loaderLookup() searches every native library loaded by the classloader rather than a named one, so archive_* calls meant for the system libarchive can resolve into the copy vendored here — which cpp/CMakeLists.txt configures with ENABLE_LZMA/BZip2/ZSTD/LZ4 = OFF and which therefore refuses archives the system library reads without trouble (LZMA codec is unsupported, BZ2 …, ZSTD …).

Which of the two libraries wins is decided by hash iteration order over library paths in jdk.internal.loader.NativeLibraries (first hit wins over a ConcurrentHashMap), and PanamaConstants extracts the .so to a randomly named temp directory on every start. So it is settled once per JVM launch and then holds for the life of the process — a process either reads every archive correctly or none of them. Load order makes no difference; I tested both.

It is also quietly hard to diagnose: the library exports archive_version_string and archive_version_number but not archive_version_details, so the usual "which libarchive am I talking to?" check answers correctly even in a process where every real call is going to the wrong one.

archive_* is the one that bit me, but it is not the only exposure — roughly 391 png*, 121 jpeg*, plus gumbo*, tj*, inflate, adler*, crc* and uchardet* are exported too.

Reproduction

Two System.load calls, no framework:

System.load("<fresh temp dir>/libepub4j_native.so");  // any unique path
Archive.isAvailable();                                 // does System.loadLibrary("archive")
Archive.getEntries(Path.of("some.7z"));                // PPMd-compressed

Twelve runs, each with a freshly mktemp -d-named copy of the .so, printing which mapping in /proc/self/maps owns archive_read_next_header:

build resolves to system libarchive result
1.4.0 as published 5 / 12 5 OK, 7 FAIL (LZMA codec is unsupported)
this PR 12 / 12 12 OK, 0 FAIL (986 entries each time)

Control with epub4j not loaded at all: OK every time.

The change

  • C_VISIBILITY_PRESET / CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN on the epub4j_native target. This is safe because epub_native.h already tags every public entry point EPUB_NATIVE_API == __attribute__((visibility("default"))) on non-Windows. Visibility alone is not sufficient — it only covers this target's own translation units; the symbols coming out of the static archives need the linker.
  • A linker export filter, which is the part that actually covers the vendored code: cpp/epub4j_native.map (-Wl,--version-script, plus -Wl,--exclude-libs,ALL) on ELF, and cpp/epub4j_native.symbols (-Wl,-exported_symbols_list) on Apple.

The version tag in the map file is anonymous, so the dynamic symbol table is filtered without attaching a symbol version — the ABI seen by dlsym() / Panama FFM is byte-for-byte the contract it was before. A named tag would have versioned the exports and changed that.

Windows is left alone: it already relies on __declspec(dllexport), which suppresses MinGW auto-export.

Evidence

$ nm -D --defined-only libepub4j_native.so | grep -c ' archive_'
before: 270      after: 0

$ nm -D --defined-only libepub4j_native.so | wc -l
before: 2023     after: 43

The remaining 43 are all epub_native_*, and they are the same 43 that 1.4.0 exported: I diffed the list against the EPUB_NATIVE_API declarations in cpp/include/epub_native.h and against the 43 string constants EpubNativeHeaders.java looks up. They match one for one, so nothing the Java side binds to was lost.

./gradlew :epub4j-native:test39 tests, 0 failures, 0 skipped:

NativeHtmlCleanerTest     tests=8  skipped=0 failures=0
NativeImageProcessorTest  tests=12 skipped=0 failures=0
NativeNCXDocumentTest     tests=5  skipped=0 failures=0
NativeNavDocumentTest     tests=4  skipped=0 failures=0
NativePackageReaderTest   tests=10 skipped=0 failures=0

skipped=0 is the meaningful part — the natives loaded, so that is not a silent no-op run. Between them those cover gumbo, pugixml, and libjpeg-turbo/libpng/libwebp through the hidden-visibility build.

There is no NativeArchiveTest in the repo, so I exercised epub_native_archive_* by hand against epub4j-core/src/test/resources/testbook1.epub: open, list (12 entries), read a stored entry (mimetype) and read a deflated entry (OEBPS/content.opf, 2120 bytes, through the now-hidden static zlib) all work. Intra-.so calls do not go through the global lookup, as expected — but that seemed worth demonstrating rather than asserting.

On the provenance of these numbers: the probe runs and the test suite above come from the session in which I developed and verified this change, and I did not re-run them while preparing this PR. What I did re-verify against the built artifact here is the symbol counts — 0 archive_* exports, 43 total, all epub_native_*. Please treat the rest as a report of an earlier run rather than something reproduced today, and CI is of course the authority.

Scope and caveats

  • Scoped to symbol visibility. No behaviour change, no API change, no source or Java change.
  • Built and verified on linux-x86_64 only. The Apple -exported_symbols_list branch is written but has never been compiled, and the Windows and musl branches are untouched but likewise unexercised by me. native-classifiers.yml builds all seven — running it on this branch would settle it, and the same workflow could assert nm -D --defined-only … | grep -c ' archive_' equals 0 as a regression gate (macOS and musl need a different symbol-tool invocation).
  • -Wl,--exclude-libs,ALL is GNU-ld/gold/lld-specific. It is belt-and-braces; the version script alone is sufficient, so it is one line to drop if you would rather not carry it.
  • It is arguably an ABI reduction, even though nothing legitimate should have been depending on it: anyone who was accidentally linking against epub4j's archive_* / png_* / gumbo_* exports loses them. That may argue for a minor bump rather than a patch, and for a CHANGELOG line — but your release flow drives versions from axion tags, so I have left the version choice to you and tagged nothing here.
  • 1.5.0 on Maven Central has the identical symbol counts (2023 / 270), so this is not something that has already been fixed.

Happy to adjust any of it, or to split the macOS branch out if you would rather land Linux first.

Summary by CodeRabbit

  • Chores
    • Implemented symbol visibility controls in the native library build system to manage the public API surface.
    • Added platform-specific export configurations for macOS and Linux environments.

libepub4j_native statically links pugixml, gumbo, uchardet, zlib,
libarchive, libjpeg-turbo, libpng and libwebp, and exports all of their
symbols globally: 2023 dynamic symbols, of which only 43 are the
epub_native_* public API and 270 are libarchive's archive_* entry points.

That surface is reachable by unrelated code in the same process. A JVM
that resolves libarchive through SymbolLookup.loaderLookup() searches
every native library loaded by the classloader rather than a named one,
so archive_* calls meant for the system libarchive can bind into the copy
vendored here -- which is configured with ENABLE_LZMA/BZip2/ZSTD/LZ4 = OFF
and so refuses archives the system library reads fine. Which of the two
wins is decided by hash iteration order over library paths, and the native
library is extracted to a randomly named temp directory, so it is settled
once per JVM start.

Restrict the exported surface to the public C API:

  * C_/CXX_VISIBILITY_PRESET hidden for this target's own translation
    units (the public entry points are already tagged EPUB_NATIVE_API,
    which is visibility("default") on non-Windows);
  * a linker export filter, which is what covers the symbols coming out
    of the static archives -- a version script on ELF platforms, an
    exported-symbols list on Apple.

The version tag is anonymous, so the dynamic symbol table is filtered
without attaching a symbol version and the ABI seen by dlsym()/FFM is
unchanged. Windows is untouched; it already relies on __declspec(dllexport)
to suppress auto-export.

Afterwards the library exports 43 symbols, all epub_native_*, matching the
43 declared EPUB_NATIVE_API in epub_native.h and the 43 looked up by
EpubNativeHeaders.java; archive_* exports go from 270 to 0.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 83f7c411-0be6-4eb8-8228-112e48168dd6

📥 Commits

Reviewing files that changed from the base of the PR and between b0dd381 and e40abdf.

⛔ Files ignored due to path filters (1)
  • epub4j-native/cpp/epub4j_native.map is excluded by !**/*.map
📒 Files selected for processing (2)
  • epub4j-native/cpp/CMakeLists.txt
  • epub4j-native/cpp/epub4j_native.symbols
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • grimmory-tools/grimmory-docs (manual)
📜 Recent review details
🔇 Additional comments (4)
epub4j-native/cpp/CMakeLists.txt (3)

411-434: LGTM: visibility preset rationale and configuration.

The comment clearly explains the symbol-collision problem and the two-mechanism fix. Setting C_VISIBILITY_PRESET hidden, CXX_VISIBILITY_PRESET hidden, and VISIBILITY_INLINES_HIDDEN ON is a correct and standard approach, consistent with the EPUB_NATIVE_API visibility attribute already defined in epub4j-native/cpp/include/epub_native.h.


436-451: LGTM: shared-only gating and per-platform branching.

Gating the export filter on EPUB4J_NATIVE_BUILD_SHARED AND NOT MSVC is correct: static-library targets don't need export filtering, and Windows is explicitly left unchanged as stated in the PR objectives. The APPLE / elseif(NOT WIN32) branching correctly separates Mach-O (-exported_symbols_list) from ELF (--version-script) linker syntax.


443-449: 🗄️ Data Integrity & Integration

No change needed. epub4j-native/cpp/epub4j_native.map is present and uses GNU ld version-script syntax.

epub4j-native/cpp/epub4j_native.symbols (1)

1-4: LGTM: correct Apple export-list syntax.

The _epub_native_* wildcard pattern is correct for Apple ld's -exported_symbols_list, and the leading underscore correctly accounts for the Mach-O C symbol prefix, as noted in the file's own comment.


📝 Walkthrough

Walkthrough

The native library build now hides default C and C++ symbols. macOS and Linux use linker filters to export only the intended native API symbols.

Changes

Native symbol visibility

Layer / File(s) Summary
Configure native symbol exports
epub4j-native/cpp/CMakeLists.txt, epub4j-native/cpp/epub4j_native.symbols
The target enables hidden default visibility. macOS uses an exported-symbol list. Linux uses a version script and excludes symbols from static libraries. The macOS list exports _epub_native_* symbols.

Estimated code review effort: 3 (Moderate) | ~15–30 minutes

Suggested labels: enhancement

Poem

A rabbit checks the linker gate,
Hiding symbols from the crate.
Native names hop into view,
Static helpers stay out too.
Clean exports now decorate the trail.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title uses valid conventional commit format and accurately describes the symbol export restriction.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the enhancement New feature or request label Aug 4, 2026
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Hide vendored symbols in libepub4j_native; export only epub_native_*

🐞 Bug fix ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

AI Description

• Restrict libepub4j_native exported symbols to the public epub_native_* C API.
• Prevent runtime symbol interposition with statically linked dependencies (e.g., libarchive).
• Add platform-specific linker export filters for ELF (version script) and Mach-O (export list).
Diagram

graph TD
  A["CMakeLists.txt"] --> B["Visibility presets"] --> C["Linker export filters"] --> D(["libepub4j_native"])
  D --> E["Exports: epub_native_*"]
  D --> F["Static deps (vendored)"] --> G["Hidden symbols"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Rely on -fvisibility=hidden only
  • ➕ Simple and portable across toolchains
  • ➕ No extra map/symbol list files to maintain
  • ➖ Does not reliably hide symbols coming from statically linked archives
  • ➖ Does not address exported dependency symbols (the core issue)
2. Use only --exclude-libs,ALL (ELF) without a version script
  • ➕ Less maintenance than a version script
  • ➕ Often effective for hiding archive-provided symbols
  • ➖ Toolchain/flag behavior can vary; still risks unintended exports
  • ➖ Does not clearly document/guarantee the intended exported ABI surface
3. Dynamically link third-party deps instead of static vendoring
  • ➕ Avoids bundling duplicate copies into one .so/.dylib
  • ➕ System libraries handle their own symbol/export policy
  • ➖ More complex packaging/distribution; increases runtime dependency surface
  • ➖ Harder to ensure consistent features across platforms (the current vendoring goal)

Recommendation: Keep the PR’s current approach (hidden-by-default + explicit linker export filtering). It is the most robust way to ensure that only epub_native_* is exported while still statically bundling dependencies for portability; the version script / exported-symbols list makes the exported ABI explicit and prevents accidental interposition issues like libarchive symbol hijacking in JVM loader-wide lookups.

Files changed (3) +62 / -0

Bug fix (1) +42 / -0
CMakeLists.txtHide default symbol visibility and add platform export filters +42/-0

Hide default symbol visibility and add platform export filters

• Sets hidden-by-default C/C++ visibility for the epub4j_native target (keeping public entry points exported via EPUB_NATIVE_API). Adds platform-specific linker options: exported_symbols_list on Apple and a GNU ld version script plus --exclude-libs,ALL on ELF platforms, with LINK_DEPENDS to trigger relinks when rules change.

epub4j-native/cpp/CMakeLists.txt

Other (2) +20 / -0
epub4j_native.mapAdd ELF version script exporting only epub_native_* +17/-0

Add ELF version script exporting only epub_native_*

• Introduces a GNU ld/gold/lld version script that marks epub_native_* as global and everything else local. Uses an anonymous version node to filter the dynamic symbol table without introducing symbol versioning semantics.

epub4j-native/cpp/epub4j_native.map

epub4j_native.symbolsAdd Mach-O exported symbol list for epub_native_* +3/-0

Add Mach-O exported symbol list for epub_native_*

• Adds an Apple ld exported symbols list restricting exports to _epub_native_*. This prevents vendored static dependency symbols from appearing as global dynamic symbols in the produced .dylib.

epub4j-native/cpp/epub4j_native.symbols

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

prakunin added a commit to prakunin/epub4j that referenced this pull request Aug 4, 2026
Fork-only workflow. Builds all seven native classifiers from a tagged ref,
asserts that each produced binary exports exactly the 43 epub_native_* entry
points and zero archive_* symbols, then publishes epub4j-core and epub4j-native
into the gh-pages branch as a plain static Maven repository at
https://prakunin.github.io/epub4j/maven/ .

Not intended for upstream: the PR at grimmory-tools#39 carries only the
62-line cpp/ source change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant