From fc7b262cf614733daa3be91d672ff83a2227d09e Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Mon, 10 Aug 2026 08:17:43 -0700 Subject: [PATCH] feat(build): add glibc-static supervisor libc variant The supervisor binary runs inside sandbox images whose libc and glibc version are unknown at build time, so it must be statically linked. Add SUPERVISOR_LIBC to select between the default musl variant and a new glibc-static variant that builds the GNU target with +crt-static. glibc-static has no cross-compile path: zig cc accepts -static for *-linux-gnu targets and emits a dynamically linked binary anyway. The staging script therefore refuses a cross-arch request for that variant rather than silently degrading linkage, and requires a native per-architecture build. Add verify-static-binary.sh, run after every supervisor build in both the staging script and CI so linkage cannot regress unnoticed for either variant. It inspects via readelf (or greadelf/llvm-readelf) and fails closed rather than trusting the tool's exit status: every inspection must produce no diagnostics, the input must be an executable ELF (ET_EXEC, or ET_DYN with DF_1_PIE) whose PT_LOAD segments all lie within the file, whose dynamic table agrees with PT_DYNAMIC, and which carries no PT_INTERP and no DT_NEEDED. That rejects a dynamically linked, truncated, corrupt, non-ELF, or shared-object input that naive parsing would misread as static. Hosts without any inspector (e.g. macOS, which ships no binutils) skip with a warning; Linux, including CI, requires one and fails closed. No image or release workflow builds the glibc-static variant, so add a dedicated supervisor-static-validate workflow that builds it on both architectures and runs the verifier. rust-native-build.yml uses self-hosted runners, which reject pull_request-triggered jobs, so it validates in the merge queue and on pushes to main that touch the build inputs, plus a nightly schedule, so the GNU + crt-static build branch cannot regress unnoticed. The default is unchanged, so image, release, and CI behavior is identical. Selecting glibc-static statically links LGPL glibc into a redistributed binary, which is why it is opt-in. Signed-off-by: Mrunal Patel Co-authored-by: Emilien Macchi --- .github/workflows/rust-native-build.yml | 57 ++++- .../workflows/supervisor-static-validate.yml | 70 ++++++ architecture/build.md | 47 +++- deploy/docker/Dockerfile.supervisor | 9 +- tasks/scripts/stage-prebuilt-binaries.sh | 71 ++++-- tasks/scripts/verify-static-binary.sh | 213 ++++++++++++++++++ 6 files changed, 434 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/supervisor-static-validate.yml create mode 100755 tasks/scripts/verify-static-binary.sh diff --git a/.github/workflows/rust-native-build.yml b/.github/workflows/rust-native-build.yml index 8ea4df2f3a..c995c1b400 100644 --- a/.github/workflows/rust-native-build.yml +++ b/.github/workflows/rust-native-build.yml @@ -5,10 +5,14 @@ name: Rust Image Binary Build (openshell-gateway / openshell-sandbox / openshell # Build Rust binaries per Linux architecture before the Docker image build # consumes them as prebuilt artifacts. Gateway images use GNU-linked binaries -# for the NVIDIA distroless C/C++ runtime; supervisor and cli images use musl/static +# for the NVIDIA distroless C/C++ runtime; supervisor and cli images use static # binaries so the final image can remain scratch. Gateway GNU binaries are # built with an explicit glibc 2.28 floor so image, package, and tarball # artifacts share the same host portability contract. +# +# The supervisor libc is selectable via the `supervisor-libc` input (musl or +# glibc-static). Both variants are fully static and are verified as such, +# because the supervisor is executed from inside arbitrary sandbox images. on: workflow_call: @@ -21,6 +25,11 @@ on: description: "Linux architecture to build (amd64 or arm64)" required: true type: string + supervisor-libc: + description: "libc variant for the sandbox component (musl or glibc-static)" + required: false + type: string + default: "musl" cargo-version: description: "Pre-computed cargo version (skips internal git-based computation)" required: false @@ -76,10 +85,12 @@ jobs: COMPONENT: ${{ inputs.component }} ARCH: ${{ inputs.arch }} FEATURES: ${{ inputs.features }} + SUPERVISOR_LIBC: ${{ inputs['supervisor-libc'] }} # Partition the GHA sccache cache per (component, arch). Without this, # concurrent jobs collide on the same cache key and later-starting - # writers hit 409 Conflict. - SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }} + # writers hit 409 Conflict. The sandbox component also partitions per + # libc variant so musl and glibc-static builds do not evict each other. + SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }}${{ inputs.component == 'sandbox' && format('-{0}', inputs['supervisor-libc']) || '' }} container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -132,9 +143,28 @@ jobs: ;; esac + # The sandbox binary must stay fully static. musl gets there via the + # musl target; glibc-static uses the GNU target with +crt-static and + # relies on this job running natively on the target architecture, + # because zig cannot statically link glibc. + static_libc=musl + if [[ "$COMPONENT" == "sandbox" ]]; then + case "$SUPERVISOR_LIBC" in + musl) static_libc=musl ;; + glibc-static) static_libc=gnu ;; + *) + echo "unsupported supervisor-libc: $SUPERVISOR_LIBC (expected musl or glibc-static)" >&2 + exit 1 + ;; + esac + fi + case "$ARCH" in amd64) - if [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then + if [[ "$COMPONENT" == "sandbox" && "$static_libc" == "gnu" ]]; then + target=x86_64-unknown-linux-gnu + zig_target= + elif [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then target=x86_64-unknown-linux-musl zig_target=x86_64-linux-musl else @@ -143,7 +173,10 @@ jobs: fi ;; arm64) - if [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then + if [[ "$COMPONENT" == "sandbox" && "$static_libc" == "gnu" ]]; then + target=aarch64-unknown-linux-gnu + zig_target= + elif [[ "$COMPONENT" == "sandbox" || "$COMPONENT" == "cli" ]]; then target=aarch64-unknown-linux-musl zig_target=aarch64-linux-musl else @@ -167,7 +200,7 @@ jobs: - name: Cache Rust target and registry uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 with: - shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} + shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}${{ inputs.component == 'sandbox' && format('-{0}', inputs['supervisor-libc']) || '' }}-zig-wrapper-${{ hashFiles('tasks/scripts/setup-zig-cc-wrapper.sh') }} cache-directories: .cache/sccache cache-targets: "true" @@ -239,6 +272,11 @@ jobs: cargo_cmd=(cargo zigbuild) build_target="${{ steps.target.outputs.zig_target }}" args+=(--features bundled-z3) + elif [[ "${{ inputs.component }}" == "sandbox" && "$SUPERVISOR_LIBC" == "glibc-static" ]]; then + # Static glibc requires the native toolchain's libc.a (build-essential + # in the CI image); cargo-zigbuild is not usable here because zig + # accepts -static for *-linux-gnu and links dynamically anyway. + export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static" fi args+=( --release @@ -276,6 +314,13 @@ jobs: BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" tasks/scripts/verify-glibc-symbols.sh 2.28 "$BIN" + - name: Verify static linkage + if: inputs.component == 'sandbox' + run: | + set -euo pipefail + BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" + tasks/scripts/verify-static-binary.sh "$BIN" + - name: Stage binary for prebuilt layout run: | set -euo pipefail diff --git a/.github/workflows/supervisor-static-validate.yml b/.github/workflows/supervisor-static-validate.yml new file mode 100644 index 0000000000..3b460a4588 --- /dev/null +++ b/.github/workflows/supervisor-static-validate.yml @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Supervisor Static Linkage Validation + +# The glibc-static supervisor variant (SUPERVISOR_LIBC=glibc-static) has no +# other CI caller: docker-build.yml builds the default musl variant, so the +# GNU + crt-static build branch and its native-only, per-arch requirements are +# never exercised by image or release CI. Build the variant here on both +# architectures so it cannot regress unnoticed. rust-native-build.yml runs +# verify-static-binary.sh for the sandbox component, which fails the job on any +# dynamic linkage. +# +# Linkage can change from a new/updated dependency or a source change, not just +# from the build scripts, so the push path filters cover the workspace manifests +# and crate sources in addition to the build tooling. A nightly schedule is the +# unfiltered backstop for anything the filters miss. +# +# rust-native-build.yml runs on NVIDIA self-hosted runners, which reject jobs +# triggered by `pull_request`. This workflow therefore follows the repo's +# self-hosted convention (see branch-checks.yml / branch-e2e.yml): validate in +# the merge queue (pre-merge), on push to main (post-merge), nightly, and on +# demand — never on `pull_request`. + +on: + merge_group: + types: [checks_requested] + push: + branches: [main] + paths: + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + - "rust-toolchain.toml" + - "mise.toml" + - "mise.lock" + - ".cargo/config.toml" + - "tasks/scripts/stage-prebuilt-binaries.sh" + - "tasks/scripts/verify-static-binary.sh" + - ".github/workflows/rust-native-build.yml" + - ".github/workflows/supervisor-static-validate.yml" + schedule: + # Nightly (04:17 UTC) unfiltered run so a linkage regression cannot slip + # through the path filters unnoticed. Schedules run only on the default branch. + - cron: "17 4 * * *" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + packages: read + +jobs: + glibc-static: + name: glibc-static supervisor (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + arch: [amd64, arm64] + uses: ./.github/workflows/rust-native-build.yml + with: + component: sandbox + arch: ${{ matrix.arch }} + supervisor-libc: glibc-static + artifact-name: supervisor-glibc-static-${{ matrix.arch }} + retention-days: 1 + secrets: inherit diff --git a/architecture/build.md b/architecture/build.md index 47fb4a668d..fb4747af28 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -68,6 +68,31 @@ The gateway bundles z3 into the release binary so Linux packages, standalone tarballs, and gateway images do not depend on distro-specific z3 shared-library SONAMEs. +The supervisor is the one binary whose libc is selectable, because it is the one +binary executed inside a userland OpenShell does not control. `SUPERVISOR_LIBC` +chooses between `musl` (default) and `glibc-static`. Both produce a fully static +binary; the choice does not change the runtime layout or the supervisor image base. +Static linkage is a hard requirement rather than a preference, so both variants +are verified by `tasks/scripts/verify-static-binary.sh`, which fails the build on +any `PT_INTERP` or `DT_NEEDED` entry. + +The two variants differ only in build-time constraints: + +| | `musl` (default) | `glibc-static` | +|---|---|---| +| Cross-compiles | yes, via `cargo zigbuild` | no — must build natively per architecture | +| Host requirement | zig + cargo-zigbuild | glibc static libraries (`glibc-static` on Fedora/RHEL, `libc6-dev` on Debian/Ubuntu) | +| libc license | MIT | LGPL-2.1-or-later, statically linked | + +`cargo zigbuild` cannot produce the `glibc-static` variant: `zig cc` accepts +`-static` for `*-linux-gnu` targets and emits a dynamically linked binary +anyway. The staging script therefore refuses to cross-compile that variant +instead of silently degrading linkage. + +Selecting `glibc-static` statically links LGPL glibc into a redistributed +binary, which carries relinking obligations that musl (MIT) does not. Treat the +default as the shipping configuration unless that has been reviewed. + ## Container Builds The Docker image pipeline is a two-step flow: build the Rust binary natively @@ -91,9 +116,11 @@ package-managed VM support does not raise the package runtime requirement. Gateway staging and release workflows set up the Zig C/C++ wrapper before bundled Z3 builds and verify the maximum referenced `GLIBC_*` symbol version before publishing or copying artifacts. -Supervisor binaries remain static musl and use `cargo zigbuild` when available, -including native CPU architectures, so C dependencies are compiled for the musl -target instead of the host GNU libc target. Local Docker image tasks infer the +Supervisor binaries are static in every configuration. The default `musl` +variant uses `cargo zigbuild` when available, including native CPU +architectures, so C dependencies are compiled for the musl target instead of the +host GNU libc target. The `glibc-static` variant uses plain `cargo build` with +`+crt-static` and requires a native per-architecture build. Local Docker image tasks infer the target architecture from `DOCKER_PLATFORM` when set. Otherwise, they require valid container engine host metadata and fail when the engine query is unavailable or reports an unsupported architecture, avoiding host-kernel @@ -114,11 +141,15 @@ Runtime layout: as a release artifact. Linux GNU VM driver binaries must not reference `GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this before publishing artifacts. -- **Supervisor**: Alpine base with `nftables`, static musl binary at - `/openshell-sandbox`. Static linkage keeps the binary usable when the image - is mounted/extracted into sandbox environments (Docker extraction, Podman - image volumes, Kubernetes init-container copy-self), while `nftables` supports - Kubernetes supervisor sidecar egress enforcement. +- **Supervisor**: Alpine base with `nftables`, static binary at + `/openshell-sandbox` (musl by default; see `SUPERVISOR_LIBC` above). Static + linkage keeps the binary usable when the image is mounted/extracted into + sandbox environments (Docker extraction, Podman image volumes, Kubernetes + init-container copy-self), whose libc and glibc version are not known at build + time, while `nftables` supports Kubernetes supervisor sidecar egress + enforcement. The VM driver bundles its own supervisor build + (`tasks/scripts/vm/build-supervisor-bundle.sh`) and does not read + `SUPERVISOR_LIBC`. Gateway image builds bake the corresponding supervisor image tag into the gateway binary so Docker sandboxes do not depend on `:latest` by default. diff --git a/deploy/docker/Dockerfile.supervisor b/deploy/docker/Dockerfile.supervisor index c760bbc890..c77c5c0aff 100644 --- a/deploy/docker/Dockerfile.supervisor +++ b/deploy/docker/Dockerfile.supervisor @@ -15,9 +15,12 @@ # # Use tasks/scripts/docker-build-image.sh supervisor (or `mise run build:docker:supervisor`) # to stage the binary and build the image in one step. CI builds the binary -# per-architecture via the `rust-native-build.yml` workflow (with the musl -# target) and uploads it as an artifact, which is downloaded into the same -# staging directory before the image build job runs. +# per-architecture via the `rust-native-build.yml` workflow and uploads it as an +# artifact, which is downloaded into the same staging directory before the image +# build job runs. +# +# The binary is static under either supported libc variant (`SUPERVISOR_LIBC`: +# musl by default, or glibc-static), so this Alpine base runs it unchanged. FROM alpine:3.22 AS supervisor diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index 331d45a5b4..b7eb1dad74 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -25,21 +25,20 @@ normalize_arch() { target_triple() { local libc=${2:-gnu} - case "$1" in - amd64) - if [[ "$libc" == "musl" ]]; then - echo "x86_64-unknown-linux-musl" - else - echo "x86_64-unknown-linux-gnu" - fi - ;; - arm64) - if [[ "$libc" == "musl" ]]; then - echo "aarch64-unknown-linux-musl" - else - echo "aarch64-unknown-linux-gnu" - fi + local suffix + case "$libc" in + musl) suffix=musl ;; + # gnu-static builds the GNU target with +crt-static, so it shares the + # gnu triple. + gnu|gnu-static) suffix=gnu ;; + *) + echo "unsupported libc: $libc" >&2 + exit 1 ;; + esac + case "$1" in + amd64) echo "x86_64-unknown-linux-${suffix}" ;; + arm64) echo "aarch64-unknown-linux-${suffix}" ;; *) echo "unsupported architecture: $1" >&2 exit 1 @@ -47,6 +46,25 @@ target_triple() { esac } +# Resolve the supervisor libc variant. Both options produce a fully static +# binary because the supervisor is executed from inside arbitrary sandbox +# images; see verify-static-binary.sh. +# +# Scope: this selects the libc for the supervisor *image* binary. The VM driver +# bundles its own supervisor build (tasks/scripts/vm/build-supervisor-bundle.sh) +# and is not affected by this setting. +supervisor_libc() { + local selection=${SUPERVISOR_LIBC:-musl} + case "$selection" in + musl) echo "musl" ;; + glibc-static) echo "gnu-static" ;; + *) + echo "unsupported SUPERVISOR_LIBC: ${selection} (expected musl or glibc-static)" >&2 + exit 1 + ;; + esac +} + host_arch() { normalize_arch "$(uname -m)" } @@ -113,7 +131,7 @@ resolve_component() { supervisor) crate=openshell-sandbox binary=openshell-sandbox - target_libc=musl + target_libc=$(supervisor_libc) ;; *) echo "unsupported binary component: $1" >&2 @@ -152,6 +170,7 @@ build_component_for_arch() { local current_host_os local current_host_arch local binary_path + local build_rustflags resolve_component "$component" target="$(target_triple "$arch" "$target_libc")" @@ -165,6 +184,7 @@ build_component_for_arch() { cargo_subcommand=(cargo build) build_target="$target" + build_rustflags="${RUSTFLAGS:-}" if [[ "$component" == "gateway" ]]; then if has_cargo_zigbuild; then @@ -174,6 +194,20 @@ build_component_for_arch() { echo "Error: cargo-zigbuild + zig are required to build ${binary} with the glibc 2.28 floor." >&2 exit 1 fi + elif [[ "$target_libc" == "gnu-static" ]]; then + # `zig cc` accepts `-static` for *-linux-gnu and emits a dynamically linked + # binary anyway, so cargo-zigbuild cannot produce this variant and there is + # no cross-compile fallback. Require a native toolchain that can link glibc + # statically (Fedora/RHEL: glibc-static, Debian/Ubuntu: libc6-dev). + build_rustflags="${build_rustflags} -C target-feature=+crt-static" + if [[ "$current_host_os" != "Linux" || "$current_host_arch" != "$arch" ]]; then + echo "Error: SUPERVISOR_LIBC=glibc-static cannot build ${binary} for linux/${arch} on ${current_host_os}/${current_host_arch}." >&2 + echo "cargo-zigbuild cannot statically link glibc, so this variant has no cross-compile path." >&2 + echo "Build on a linux/${arch} host with glibc static libraries installed, use SUPERVISOR_LIBC=musl," >&2 + echo "or provide prebuilt binaries in:" >&2 + echo " deploy/docker/.build/prebuilt-binaries/${arch}/" >&2 + exit 1 + fi elif [[ "$target_libc" == "musl" ]] && has_cargo_zigbuild; then cargo_subcommand=(cargo zigbuild) elif [[ "$current_host_os" != "Linux" || "$current_host_arch" != "$arch" ]]; then @@ -187,7 +221,7 @@ build_component_for_arch() { fi fi - echo "Building ${binary} for linux/${arch} (${build_target})..." + echo "Building ${binary} for linux/${arch} (${build_target}, libc: ${target_libc})..." mise x -- rustup target add "$target" >/dev/null 2>&1 || true args=( @@ -208,12 +242,17 @@ build_component_for_arch() { if [[ -n "${OPENSHELL_CARGO_VERSION:-}" ]]; then export GIT_DIR=/nonexistent fi + if [[ -n "$build_rustflags" ]]; then + export RUSTFLAGS="$build_rustflags" + fi CARGO_INCREMENTAL=0 mise x -- "${cargo_subcommand[@]}" "${args[@]}" ) binary_path="${ROOT}/target/${target}/release/${binary}" if [[ "$component" == "gateway" ]]; then "$SCRIPT_DIR/verify-glibc-symbols.sh" 2.28 "$binary_path" + elif [[ "$component" == "supervisor" ]]; then + "$SCRIPT_DIR/verify-static-binary.sh" "$binary_path" fi mkdir -p "$stage" diff --git a/tasks/scripts/verify-static-binary.sh b/tasks/scripts/verify-static-binary.sh new file mode 100755 index 0000000000..006158ca62 --- /dev/null +++ b/tasks/scripts/verify-static-binary.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +# Verify a binary is a genuine, complete, fully static executable. +# +# The supervisor is executed from inside arbitrary sandbox images (Docker +# extraction, Podman image volumes, the Kubernetes copy-self path), so any +# dynamic linkage breaks it on musl-based images and on images whose glibc is +# older than the build host's. Both supported supervisor libc variants (musl +# and glibc-static) must therefore produce a static binary. +# +# This check exists because the failure is silent: `zig cc` accepts `-static` +# for `*-linux-gnu` targets and emits a dynamically linked binary anyway, so a +# toolchain change can quietly downgrade linkage without failing the build. +# +# The verifier must also fail closed on malformed input; readelf can exit 0 on a +# damaged ELF, which naive parsing would read as "no interpreter, no +# dependencies". The checks below therefore require, for each binary: +# * an executable ELF: ET_EXEC (classic static) or ET_DYN (static-PIE); +# * a fully readable ELF header, program header table, and dynamic section +# (readelf must exit 0 AND emit no diagnostics — see readelf_strict); +# * at least one PT_LOAD segment, every PT_LOAD contained within the file +# (catches truncation that readelf does not otherwise report); +# * no PT_INTERP and no DT_NEEDED (the actual static-linkage properties); +# * for ET_DYN, the DF_1_PIE flag, which a static-PIE executable sets and a +# shared library does not. +# +# Accepts both classic static and static-PIE binaries. static-PIE keeps a +# PT_DYNAMIC segment for self-relocation, so linkage is judged by the absence of +# PT_INTERP and DT_NEEDED, not by the absence of a dynamic section. + +usage() { + echo "Usage: verify-static-binary.sh [binary ...]" >&2 +} + +if [[ $# -lt 1 ]]; then + usage + exit 2 +fi + +# Resolve a readelf-compatible inspector. macOS ships none of these; Homebrew +# binutils provides `greadelf` and LLVM provides `llvm-readelf`, both of which +# emit GNU-style output this script parses. Prefer GNU readelf, then greadelf, +# then llvm-readelf. +READELF="" +for candidate in readelf greadelf llvm-readelf; do + if command -v "$candidate" >/dev/null 2>&1; then + READELF=$candidate + break + fi +done + +if [[ -z $READELF ]]; then + host_os="" + command -v uname >/dev/null 2>&1 && host_os=$(uname -s 2>/dev/null || true) + # Skip only on a host positively identified as non-Linux — e.g. a macOS dev + # cross-building the Linux supervisor via cargo-zigbuild, where mise installs + # no binutils. Linux (including CI), or any host whose OS cannot be determined, + # fails closed so a missing inspector never silently passes. Static linkage is + # still enforced in CI, which runs on Linux. + if [[ "$host_os" == "Linux" || -z $host_os ]]; then + echo "error: readelf (or greadelf/llvm-readelf) is required to inspect binary linkage" >&2 + exit 2 + fi + echo "warning: no readelf/greadelf/llvm-readelf found on ${host_os}; skipping static-linkage verification." >&2 + echo " install GNU binutils (greadelf) or LLVM (llvm-readelf) to verify locally; CI enforces it on Linux." >&2 + exit 0 +fi + +# Explicit template: BSD/macOS mktemp requires one, GNU mktemp accepts it. +readelf_err=$(mktemp "${TMPDIR:-/tmp}/verify-static-binary.XXXXXXXX") +trap 'rm -f "$readelf_err"' EXIT + +# Run readelf and print its stdout. Fails (returns non-zero) if readelf exits +# non-zero OR writes anything to stderr, leaving the diagnostics in +# $readelf_err. readelf reports a truncated or corrupt ELF on stderr while still +# exiting 0, so the stderr check — not the exit code — is what makes a damaged +# file fail closed instead of reading as "no PT_INTERP, no DT_NEEDED". +readelf_strict() { + local out + out=$("$READELF" "$@" 2>"$readelf_err") || return 1 + [[ -s "$readelf_err" ]] && return 1 + printf '%s\n' "$out" + return 0 +} + +failed=0 + +for binary in "$@"; do + if [[ ! -f $binary ]]; then + echo "error: binary not found: $binary" >&2 + failed=1 + continue + fi + + echo "==> Inspecting $binary" + + # llvm-readelf rejects the `--` end-of-options marker that GNU readelf accepts, + # so make a leading-dash path safe for either tool by prefixing "./" instead. + case "$binary" in + -*) scan_path="./$binary" ;; + *) scan_path="$binary" ;; + esac + + if command -v file >/dev/null 2>&1; then + file "$scan_path" || true + fi + + # The ELF header and the full program header table must be readable. A + # truncated or non-ELF file makes readelf emit a diagnostic, which fails here + # instead of being misread as a static binary. + if ! headers=$(readelf_strict --wide --file-header --program-headers "$scan_path"); then + echo "error: $binary: unable to read a complete ELF (truncated, malformed, or not an ELF)" >&2 + sed 's/^/ /' "$readelf_err" >&2 || true + failed=1 + continue + fi + + if grep -Eq '^[[:space:]]*Type:[[:space:]]+EXEC' <<<"$headers"; then + elf_type=EXEC + elif grep -Eq '^[[:space:]]*Type:[[:space:]]+DYN' <<<"$headers"; then + elf_type=DYN + else + echo "error: $binary is not an executable ELF (expected ET_EXEC or ET_DYN)" >&2 + failed=1 + continue + fi + + # Every runnable ELF has at least one PT_LOAD segment. Its absence means the + # program header table was truncated or the input is not a program image. + if ! grep -qw 'LOAD' <<<"$headers"; then + echo "error: $binary has no PT_LOAD segment; it is truncated or not an executable" >&2 + failed=1 + continue + fi + + # Every PT_LOAD must lie within the file. readelf can exit 0 with empty stderr + # on a file whose section headers were stripped even though a LOAD segment runs + # past EOF, so validate p_offset + p_filesz <= file size explicitly rather than + # trusting readelf to notice the truncation. + # wc -c is portable (GNU stat -c / BSD stat -f differ); arithmetic strips any + # leading whitespace BSD wc prints. The redirect also tolerates a '-' path. + file_size=$(( $(wc -c < "$binary") )) + load_past_eof=0 + while read -r ph_type ph_off _ph_va _ph_pa ph_fsize _ph_rest; do + [[ "$ph_type" == "LOAD" ]] || continue + # ph_off and ph_fsize are hex (e.g. 0x6a8440); bash arithmetic parses 0x. + if (( ph_off + ph_fsize > file_size )); then + echo "error: $binary: PT_LOAD at ${ph_off} (filesz ${ph_fsize}) extends past end of file (${file_size} bytes); it is truncated" >&2 + load_past_eof=1 + fi + done <<<"$headers" + if (( load_past_eof )); then + failed=1 + continue + fi + + if grep -qw 'INTERP' <<<"$headers"; then + echo "error: $binary has a program interpreter (PT_INTERP); it is dynamically linked" >&2 + grep -w -A1 'INTERP' <<<"$headers" >&2 || true + failed=1 + continue + fi + + # The dynamic section must also be fully readable. A classic static binary has + # none (readelf says so on stdout and exits cleanly, with no stderr); a + # static-PIE has one without any DT_NEEDED entries. + if ! dynamic=$(readelf_strict --wide --dynamic "$scan_path"); then + echo "error: $binary: unable to read the ELF dynamic section (truncated or malformed)" >&2 + sed 's/^/ /' "$readelf_err" >&2 || true + failed=1 + continue + fi + + # Anchor the dynamic table to PT_DYNAMIC. GNU readelf --dynamic reads the + # SHT_DYNAMIC *section*, whose file offset can be pointed away from the real + # PT_DYNAMIC *segment* to hide DT_NEEDED entries (llvm-readelf warns on this; + # GNU does not). Require the section offset readelf used to match the + # PT_DYNAMIC segment offset from the program headers; fail closed on any + # disagreement. Compare numerically so 0x0b1da8 and 0xb1da8 are equal. + dyn_seg_off=$(awk '$1 == "DYNAMIC" { print $2; exit }' <<<"$headers") + dyn_sec_off=$(grep -oE 'Dynamic section at offset 0x[0-9a-fA-F]+' <<<"$dynamic" | grep -oE '0x[0-9a-fA-F]+' | head -1 || true) + if [[ -n $dyn_seg_off || -n $dyn_sec_off ]]; then + if [[ -z $dyn_seg_off || -z $dyn_sec_off ]] || (( dyn_seg_off != dyn_sec_off )); then + echo "error: $binary: dynamic table location mismatch (PT_DYNAMIC ${dyn_seg_off:-none}, section ${dyn_sec_off:-none}); malformed or tampered" >&2 + failed=1 + continue + fi + fi + + if grep -qw 'NEEDED' <<<"$dynamic"; then + echo "error: $binary depends on shared libraries (DT_NEEDED); it is dynamically linked" >&2 + grep -w 'NEEDED' <<<"$dynamic" >&2 || true + failed=1 + continue + fi + + # An ET_DYN static-PIE executable sets DT_FLAGS_1 DF_1_PIE; a shared library + # (also ET_DYN, and possibly without PT_INTERP/DT_NEEDED) does not. Require the + # flag so a .so cannot pass as a static executable. + if [[ "$elf_type" == "DYN" ]] && ! grep -E '\(FLAGS_1\)' <<<"$dynamic" | grep -qw 'PIE'; then + echo "error: $binary is an ET_DYN object without DF_1_PIE; it looks like a shared library, not a static-PIE executable" >&2 + failed=1 + continue + fi + + echo "statically linked: no PT_INTERP, no DT_NEEDED" +done + +exit "$failed"