Skip to content

Verify native binaries before packing - #6

Open
henrikottesorensen wants to merge 3 commits into
Notalib:mainfrom
henrikottesorensen:feature/native-package-verification
Open

Verify native binaries before packing#6
henrikottesorensen wants to merge 3 commits into
Notalib:mainfrom
henrikottesorensen:feature/native-package-verification

Conversation

@henrikottesorensen

Copy link
Copy Markdown
Collaborator

Adds build/verify_native_binary.sh, called from pack_runtime_package, so a binary that fails inspection never becomes a package.

Checks, per RID

Check Why it exists
Architecture matches the RID A mis-targeted binary restores cleanly and never loads.
Every P/Invoke symbol is exported Otherwise EntryPointNotFoundException at first use.
No dependency outside a per-platform allowlist Catches the libgcc_s_dw2-1.dll class of bug.
Highest required GLIBC_ version within a declared floor (Linux) The floor decides which distributions can consume the packages, and it is a property of the build image.

The expected symbols are read from the EntryPoint attributes in LibLouis.NET/NativeMethod.cs rather than listed in the script, so the check cannot drift from what the wrapper actually imports — 18 symbols today, and adding a P/Invoke extends the check automatically.

The glibc floor is MAX_GLIBC, currently 2.34, covering RHEL 9, Debian 12 and Ubuntu 22.04 and later. linux-x86 sits exactly on it. Raising it drops support for older distributions, so it should be a deliberate decision rather than a side effect of bumping the base image — which is exactly what this check makes it.

Verification

Run against all eight RIDs, and against two deliberately bad inputs:

  • an x86-64 binary declared as linux-arm64 → rejected on architecture
  • a win-x86 built without -static-libgcc → rejected on libgcc_s_dw2-1.dll

The second is the bug this repository actually shipped, so the check is demonstrated against a real regression rather than a hypothetical one.

Worth recording: that bug only reproduces with mingw gcc 10 on jammy. gcc 13 on noble does not emit the dependency at all, so an earlier attempt to reproduce it there produced an already-clean binary and looked like the check had failed. -static-libgcc stays regardless, so the output does not depend on which compiler the base image happens to ship.

Notes

  • llvm is added to the build image because binutils cannot read aarch64 PE. The ELF checks use readelf/nm, which come with build-essential; macOS uses nm/otool.
  • SKIP_NATIVE_VERIFICATION=1 bypasses the checks, for deliberately building something they were not written for.
  • This is complementary to, not overlapping with, the P/Invoke audit: this checks that symbols exist and dependencies are sane, not that the managed signatures match the native contract.

🤖 Generated with Claude Code

Adds build/verify_native_binary.sh, called from pack_runtime_package, so a
binary that fails inspection never becomes a package.

Checks per RID:

- architecture matches the RID. A mis-targeted binary produces a package
  that restores fine and never loads.
- every P/Invoke symbol is exported. The expected list is read from the
  EntryPoint attributes in NativeMethod.cs rather than kept in the script,
  so it cannot drift away from what the wrapper imports.
- no dependency outside a per-platform allowlist. This is the check that
  catches the libgcc_s_dw2-1.dll class of bug, where a binary links against
  a toolchain runtime that the package does not ship: it works on the
  machine that built it and fails everywhere else.
- Linux only, the highest required glibc symbol version stays within a
  declared floor. The floor decides which distributions can consume the
  packages and is a property of the build image, so it can rise silently
  when that image is bumped. Currently 2.34, which covers RHEL 9,
  Debian 12 and Ubuntu 22.04. linux-x86 sits exactly on it.

Verified against all eight RIDs, and against two deliberately bad inputs:
an x86-64 binary declared as linux-arm64, and a win-x86 built without
-static-libgcc, which is the bug this repository actually shipped. Both
are rejected.

Note that bug only reproduces with the mingw gcc 10 on jammy; the gcc 13
on noble does not emit the dependency at all. -static-libgcc stays so the
output does not depend on which compiler the base image happens to ship.

llvm is added to the build image because binutils cannot read aarch64 PE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems, both found by running the script from outside the
repository while verifying the 3.38.0 bump.

The symbol check passed against an empty list. expected_symbols read
NativeMethod.cs and was called through command substitution, so when the
file could not be read it printed an error, exited its own subshell, and
left the caller to compare against nothing. The output read "all 0
P/Invoke symbols exported", which looks like a pass and asserts nothing.
The list is now resolved once in the main shell, where a missing or
unparsable NativeMethod.cs stops the script.

find_tool only looked where Linux distributions put things, so on macOS
the ELF checks could not run at all: command -v nm finds BSD nm, which
has no -D. It now takes several interchangeable names in preference
order and also searches Homebrew's keg-only prefixes, so llvm-readelf,
llvm-nm and llvm-readobj are used when present. Those read ELF, PE and
Mach-O alike, so with brew install llvm all eight RIDs can be verified on
a Mac without a container.

Verified: all eight RIDs pass natively on macOS, and a script run where
NativeMethod.cs is unreachable now exits 1 instead of reporting success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@henrikottesorensen
henrikottesorensen force-pushed the feature/native-package-verification branch from 4e4593b to 71ca13b Compare August 5, 2026 11:42
CI failed fetching linux-libc-dev, a dependency of build-essential:

  E: Failed to fetch .../linux-libc-dev_5.15.0-187.197_amd64.deb  404

archive.ubuntu.com does not update its index and its pool atomically, so
a package version can still be listed after it has been removed, and the
fetch 404s. Nothing to do with the packages this image asks for; it is
luck, and a single apt-get run has none to spare.

Both apt steps now try three times, refreshing the index each time, since
a newer index is usually what resolves it. The explicit ok check is load
bearing: a bare loop that never succeeds still falls through, and the
layer would build with nothing installed and fail much later with
something unrecognisable.

Verified with --no-cache, which is the case that actually hits the
network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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