Skip to content

feat(api/ocr): verify which Tesseract OCR actually binds, and say so - #463

Open
davidyoung8196504567-sudo wants to merge 3 commits into
oculix-org:chore/global-bug-fixesfrom
davidyoung8196504567-sudo:feat/native-provenance-guard
Open

feat(api/ocr): verify which Tesseract OCR actually binds, and say so#463
davidyoung8196504567-sudo wants to merge 3 commits into
oculix-org:chore/global-bug-fixesfrom
davidyoung8196504567-sudo:feat/native-provenance-guard

Conversation

@davidyoung8196504567-sudo

Copy link
Copy Markdown

Why

Commons.loadTesseract() sets libTesseractLoaded when Legerix.loadNatives() merely doesn't throw. That means "Legerix loaded its own file by absolute path" — it says nothing about the library tess4j binds when OCR runs, because tess4j resolves the short name tesseract through JNA at its own static init and can land somewhere else. The startup banner then reports a version string, which describes the extracted file rather than the bound one.

Testing the Legerix v5.5.0-9-DO-NOT-USE payload across macOS arm64, both Linux x86-64 tiers and native Windows showed both halves of that going wrong in the field:

  • a host binding Homebrew's tesseract for OCR while the suite stayed green
  • a host where the bundled payload ships only versioned filenames, so JNA's exact-name lookup misses and OCR fails outright

Neither is visible from anything OculiX currently logs.

What this adds

NativeProvenance — reporting, not enforcement. A wrong binding is a diagnosis problem rather than a crash, and the point is that it is currently invisible.

  • Captures the tier directory loadNatives() already returns. We were discarding it. It anchors everything else, and needs no change from Legerix.
  • Checks what OCR actually bound, after the first real OCR call. Deliberately after: before it the check proves nothing, and asking JNA for an unresolved name would itself trigger the load under test. A copy that is byte-identical to ours is reported as equivalent, not a warning — otherwise tess4j's own temp extraction cries wolf on every run.
  • Flags natives that are not Legerix's own when Legerix is shaded into our fat jar. Its extraction reads getProtectionDomain().getCodeSource(), so a fat jar makes that our jar. Comparing code sources is exact; comparing filenames would misfire on the large transitive set shipped for Windows.
  • Turns the JNA link failure into a diagnosis. "Reinstall OculiX" is the wrong advice when the payload is present and merely lacks the unversioned alias JNA asks for.

The banner now names the natives directory rather than only a version, and isBindingVerified() stays false until a real OCR call has been observed — so it can never read as "fine" merely because nothing has been checked.

It already found something

Running the API with a real OCR call on a Mac with no system tesseract:

Bundled natives are present in ~/.cache/legerix/5.5.0-8/darwin-aarch64:
  libleptonica.6.dylib, libtesseract.5.dylib
Note: JNA resolves the short name 'tesseract' to the exact filename
'libtesseract.dylib', which is not among them — the bundled files carry
version suffixes only. Reinstalling will not change that.

Testing

  • NativeProvenanceTest — 6 tests, covering the diagnosis text and that isBindingVerified() is false until observed
  • Exercised end-to-end through a real OCR.readText() call, which produced the output above
  • Full API + IDE suite run on this branch and on the untouched base: identical results — 41 errors, 44 skipped, all pre-existing and headless-related. This branch adds 6 passing tests and changes nothing else

Not included, deliberately

Self-healing — creating the missing unversioned alias — writes into the cache rather than only observing it, and should be a user-visible choice rather than a silent side effect. Proposed separately.

Commons.loadTesseract() has been setting libTesseractLoaded on a false premise:
loadNatives() not throwing means Legerix loaded its own file by absolute path.
It says nothing about the library tess4j binds when OCR runs, because tess4j
resolves the short name "tesseract" through JNA at its own static init and can
land somewhere else entirely. The banner then reported a version string, which
describes the extracted file rather than the bound one.

Investigating Legerix#20 across macOS, two Linux tiers and native Windows showed
both halves of that going wrong in the field: a host binding Homebrew's tesseract
while the suite stayed green, and a host where the bundled payload ships only
versioned filenames so the JNA lookup misses and OCR fails outright.

Adds NativeProvenance, which reports rather than enforces — a wrong binding is a
diagnosis problem, not a crash, and the point is that it is currently invisible:

- captures the tier directory loadNatives() already returns and we discarded
- after the first real OCR call, resolves the short names tess4j uses and logs
  any that landed outside that directory. Deliberately after OCR: before it the
  check proves nothing, and asking JNA for an unresolved name would itself
  trigger the load under test. A copy that is byte-identical to ours is reported
  as equivalent, not as a warning, so tess4j's own temp extraction does not cry
  wolf
- flags natives that are not Legerix's own when Legerix is shaded into our jar.
  Its extraction reads getProtectionDomain().getCodeSource(), so a fat jar makes
  that our jar; comparing code sources is exact, where comparing filenames would
  misfire on the large transitive set shipped for Windows
- turns the JNA link failure into a diagnosis. "Reinstall OculiX" is the wrong
  advice when the payload is present and merely lacks the unversioned alias JNA
  asks for, which is what happens on a Mac with no system tesseract

The banner now names the natives directory instead of only a version, and
isBindingVerified() stays false until a real OCR call has been observed, so it
can never read as "fine" merely because nothing was checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
@davidyoung8196504567-sudo

Copy link
Copy Markdown
Author

On the checks shown here — worth stating rather than leaving to be inferred.

api-compile, ide-compile and CodeQL do not run on this PR. They are filtered to pull requests targeting master (and release/oculix-rc for the compile jobs), and this targets chore/global-bug-fixes as agreed on #455. The single check above is ocr-perf-bench, which fires only because this touches Commons.java. #465 proposes adding the branch to those three filters.

So the green tick here is thinner evidence than it looks. What was actually done locally:

  • API and IDE compiled from source on every commit
  • NativeProvenanceTest — 6 tests on this branch
  • the full API + IDE suite run on this branch and on the untouched base for comparison: identical results, 41 errors and 44 skipped, all pre-existing and headless-related. This branch adds passing tests and changes nothing else
  • exercised end-to-end through a real OCR.readText() call, which produced the diagnosis quoted in the description

Happy to re-run anything specific if you would rather see it from a clean checkout.

Fleet review found two defects that pointed in opposite directions, and both
were in claims the code made about itself rather than in the logic.

resolveQuietly() called NativeLibrary.getInstance(name), which loads when the
name is not cached. For any name the consumer had not bound — all three of them
on Windows — the check therefore performed the very short-name resolution it
exists to observe, and could then report a library OCR never touched as the one
servicing it. On Linux it is worse than misleading: speculatively mapping a
system tesseract alongside our bundled leptonica is how the crash we reported
upstream begins. A diagnostic must not be able to cause the fault it looks for.

It now reads JNA's own cache and reports only what is genuinely bound, matching
on getName() rather than on the map key so it does not depend on how JNA
composes that key. If the cache cannot be read it reports nothing rather than
forcing a load. Confirmed on this Mac: the check previously reported three
resolved names and now reports two, because the third was never bound by the
consumer at all — the old code was manufacturing the observation it reported.

isBindingVerified() could also return true having checked nothing. allInside
started true and every unbound name hit continue, so the all-unbound case — the
normal Windows one — came out verified. Its own javadoc promised the opposite.
Now requires at least one observation.

Also narrowed two sentences to what was actually checked: the warning speaks of
the name being bound outside the bundled directory rather than asserting what is
servicing OCR, and the link-failure note no longer generalises from one filename
to "the bundled files carry version suffixes only".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
davidyoung8196504567-sudo pushed a commit to davidyoung8196504567-sudo/Oculix that referenced this pull request Sep 4, 2026
…ser's terms

Builds on oculix-org#463, which detects that the bundled natives lack the unversioned
filename JNA's exact-name lookup asks for. Detecting it does not help a user
whose OCR is broken today, and the repair is a single symlink.

It is not done silently. This writes into a cache directory OculiX did not
create, and people hold views about that, so it is a stored three-state choice:
ASK (default, the IDE prompts), AUTO (repair at every startup), NEVER (never
write). NEVER still prints the exact ln -s command, so declining never leaves
someone worse off than not being asked.

Re-asserted on every startup rather than once at install. The cache is keyed by
version, so an upgrade extracts a fresh directory and any alias made earlier is
gone — the same reason a package manager re-points its "latest" link after each
build instead of assuming it holds. Idempotent, so if Legerix ever ships these
itself this quietly does nothing.

Which aliases are needed was measured across macOS, both Linux x86-64 tiers and
native Windows, and the rules are not symmetric:

- unversioned only for the JNA lookup. A versioned alias satisfies
  isVersionedName, enters JNA's version-pooling fallback and competes with
  whatever the distro ships — reintroducing the bug it is meant to fix
- Linux additionally needs versioned liblept.so.5, because the bundled
  tesseract's ELF NEEDED is that literal string and no such file exists. Safe
  only alongside the unversioned link, which wins the exact-name pass first
- Windows needs nothing and is excluded: tess4j binds fully versioned names it
  ships itself, and NTFS symlinks would need admin or Developer Mode

Aliases always target the real versioned library, never another alias — the
directory scan uses NOFOLLOW_LINKS, so a link made earlier in the same pass
cannot become the target of a later one and leave a chain that breaks when the
middle link goes.

Verified on a Mac with no system tesseract, where OCR previously failed outright
with UnsatisfiedLinkError: after repair all three short names resolve inside the
bundled directory, isBindingVerified() is true, and OCR returns its text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
verifyBinding() logged the byte-identical case and fell through without
touching allInside, so bindingVerified stayed true while OCR was serviced
from outside the extraction directory. That is a false pass in the normal
Windows case, where tess4j binds its own byte-identical temp copy.

The question this check answers is one of path, not content: if a short
name resolves outside our directory then our directory lost, whatever the
bytes say. Content equality cannot distinguish "our payload reached by
another route" from "a different build that happens to match", and under
a shaded jar the directory's own contents may not be ours either — so
suppressing the signal returned success in exactly the case the check
exists to detect. It now lowers the message severity and leaves the flag
false.

Carried back from the self-heal branch so this PR is correct on its own.
The two are stacked, so merging both in either order already produced this
result; the exposure was this PR merging alone if the other stalls, which
would have shipped the false pass with nothing following to correct it.

Found by a fleet reviewer reading the diffs rather than my summary of them,
after an earlier review of these PRs turned out to have been of the summary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
davidyoung8196504567-sudo pushed a commit to davidyoung8196504567-sudo/Oculix that referenced this pull request Sep 4, 2026
…ser's terms

Builds on oculix-org#463, which detects that the bundled natives lack the unversioned
filename JNA's exact-name lookup asks for. Detecting it does not help a user
whose OCR is broken today, and the repair is a single symlink.

It is not done silently. This writes into a cache directory OculiX did not
create, and people hold views about that, so it is a stored three-state choice:
ASK (default, the IDE prompts), AUTO (repair at every startup), NEVER (never
write). NEVER still prints the exact ln -s command, so declining never leaves
someone worse off than not being asked.

Re-asserted on every startup rather than once at install. The cache is keyed by
version, so an upgrade extracts a fresh directory and any alias made earlier is
gone — the same reason a package manager re-points its "latest" link after each
build instead of assuming it holds. Idempotent, so if Legerix ever ships these
itself this quietly does nothing.

Which aliases are needed was measured across macOS, both Linux x86-64 tiers and
native Windows, and the rules are not symmetric:

- unversioned only for the JNA lookup. A versioned alias satisfies
  isVersionedName, enters JNA's version-pooling fallback and competes with
  whatever the distro ships — reintroducing the bug it is meant to fix
- Linux additionally needs versioned liblept.so.5, because the bundled
  tesseract's ELF NEEDED is that literal string and no such file exists. Safe
  only alongside the unversioned link, which wins the exact-name pass first
- Windows needs nothing and is excluded: tess4j binds fully versioned names it
  ships itself, and NTFS symlinks would need admin or Developer Mode

Aliases always target the real versioned library, never another alias — the
directory scan uses NOFOLLOW_LINKS, so a link made earlier in the same pass
cannot become the target of a later one and leave a chain that breaks when the
middle link goes.

Verified on a Mac with no system tesseract, where OCR previously failed outright
with UnsatisfiedLinkError: after repair all three short names resolve inside the
bundled directory, isBindingVerified() is true, and OCR returns its text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
@julienmerconsulting

Copy link
Copy Markdown
Collaborator

@davidyoung8196504567-sudo — the decision on this one, with its reasoning and the rest of your September batch, is in #469. It is not a merge, and I would rather be contradicted there than agreed with here.

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.

2 participants