diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4acb74a4..284e6ea5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,3 +21,17 @@ updates: codeql: patterns: - github/codeql-action* + - package-ecosystem: gitsubmodule + directory: / + schedule: + interval: weekly + day: monday + time: '14:00' + cooldown: + default-days: 7 + ignore: + # libmaxminddb is pinned to release tags and compiled into the + # extension. Dependabot moves submodules to the latest commit on the + # tracked branch when no newer tag exists, which would take us off + # release tags. + - dependency-name: ext/libmaxminddb diff --git a/.github/workflows/clang-addresssanitizer.yml b/.github/workflows/clang-addresssanitizer.yml index 07a3797c..a6a66540 100644 --- a/.github/workflows/clang-addresssanitizer.yml +++ b/.github/workflows/clang-addresssanitizer.yml @@ -68,7 +68,7 @@ jobs: ./configure --with-maxminddb --enable-maxminddb-debug make clean make -j2 - NO_INTERACTION=1 make test + ../dev-bin/run-ext-tests.sh env: # -isystem as otherwise Clang generates warnings for includes there. # I don't think it should, but I haven't discovered a better way to diff --git a/.github/workflows/test-bundled.yml b/.github/workflows/test-bundled.yml new file mode 100644 index 00000000..3ef1b027 --- /dev/null +++ b/.github/workflows/test-bundled.yml @@ -0,0 +1,277 @@ +name: Bundled libmaxminddb + +on: + push: + pull_request: + schedule: + - cron: '15 4 * * SUN' + +permissions: {} + +env: + # The highest glibc symbol version a bundled build here is allowed to + # reference. This repository publishes no binaries, so nobody inherits this + # number directly -- it is a tripwire on the sources, and the floor that + # actually reaches users is the extension repository's. + # + # The floor measured here is the *build container's*, not the runner's: the + # jobs below run in shivammathur/node:latest-. That tag is unpinned, so + # this is an assertion about a moving target -- an upstream image rebuild + # onto a newer glibc will either fail this job or shift what the number + # means. Pinning the image by digest is the way to make it stable. + # + # The extension repository builds what it publishes inside a digest-pinned + # bookworm container and holds itself to 2.36. The two numbers differ on + # purpose, which is why dev-bin/gate-extension.sh takes the limit from its + # caller rather than hard-coding one. + MAX_GLIBC: '2.38' + +jobs: + bundled: + runs-on: ${{ matrix.runner }} + container: shivammathur/node:latest-${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + arch: ["amd64", "arm64v8"] + php-version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64v8 + runner: ubuntu-24.04-arm + + name: "PHP ${{ matrix.php-version }} bundled build on ${{ matrix.runner }}" + steps: + - name: Install PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php-version }} + # gmp so ReaderTest's uint128 boundary cases run rather than skip; + # they are gated on it, and uint128 is the branch this job is the + # only one to compile. + extensions: "mbstring, intl, gmp" + tools: "composer, phpize" + + - name: Checkout + # We use v1 due to https://github.com/actions/checkout/issues/334 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + + # We deliberately do not install libmaxminddb, and we do not set + # PKG_CONFIG_PATH, so that the build cannot silently fall back to a + # system library. + - name: Check that no system libmaxminddb is available + run: | + # Without this, an image with no pkg-config makes the check below + # exit 127, the `if` false, and the step green having proved + # nothing. + if ! command -v pkg-config >/dev/null; then + echo "::error::pkg-config is not installed, so this precondition cannot be verified" + exit 1 + fi + if pkg-config --exists libmaxminddb; then + echo "::error::a system libmaxminddb is installed, so this job would not test the bundled sources" + exit 1 + fi + # pkg-config only sees libraries that ship a .pc file on + # PKG_CONFIG_PATH, so a `make install` into /usr/local would slip + # past it. What actually proves the bundled code is inside the + # object is gate-extension.sh's NEEDED and undefined-MMDB_ checks. + + - name: Build extension + run: | + cd ext + phpize + ./configure --with-maxminddb --with-maxminddb-bundled + make clean + make + ../dev-bin/run-ext-tests.sh + + # Shared with maxmind/MaxMind-DB-Reader-php-ext, which runs this same + # script over the objects it publishes. Keeping one implementation means + # a check added for the released binaries is a check this job performs + # too, and neither can quietly weaken relative to the other. + - name: Check that the extension is self-contained + run: dev-bin/gate-extension.sh ext/modules/maxminddb.so + + # A prebuilt extension is installed as a lone maxminddb.so, so check that + # the object loads with no ini file and no build tree beside it. Note + # that this is not a self-containment proof: every library present at + # build time is still installed on this machine. gate-extension.sh above + # is what establishes that. + - name: Load the extension on its own + run: | + # autoupdate and autoconf >= 2.70 routinely rewrite AC_INIT's + # spacing, at which point this sed yields nothing and the + # comparison below would fail with an empty "expected". + version="$(sed -n 's/^AC_INIT(\[libmaxminddb\], \[\([^]]*\)\].*/\1/p' \ + ext/libmaxminddb/configure.ac)" + if [ -z "$version" ]; then + echo "::error::could not read the libmaxminddb version from ext/libmaxminddb/configure.ac" + exit 1 + fi + dir="$(mktemp -d)" + cp ext/modules/maxminddb.so "$dir/" + php -n -d extension="$dir/maxminddb.so" dev-bin/verify-extension.php \ + tests/data/test-data/GeoIP2-City-Test.mmdb "$version" + + # The phpt files in ext/tests decode nothing. Every behavioural assertion + # against the C code -- all twelve MMDB data types, the corrupt-database + # paths, the closed-reader paths -- lives in the PHPUnit suite, which + # test.yml runs against a libmaxminddb built from git, and which this job + # did not previously run at all. Hence the steps below. + # + # It matters most for uint128. MMDB_UINT128_IS_BYTE_ARRAY=1 is the path + # every bundled build takes, and no other job in this repository + # exercises it: they all link an external libmaxminddb, whose own + # configure finds unsigned __int128 and compiles the other branch. + # ReaderTest asserts the exact string the extension returns for 2^120, so + # a swapped high/low word or a wrong shift is caught here and nowhere + # else -- it would otherwise produce a plausible-looking hex string and + # crash nothing. + # + # The suite also reads floats and doubles, which are the only consumers + # of MMDB_LITTLE_ENDIAN. + - name: Install dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + # Both preconditions are load-bearing, and neither failure is loud on its + # own. + # + # A failed `extension=` load is a warning, not a fatal, and PHP carries + # on. phpunit.xml.dist bootstraps vendor/autoload.php, whose PSR-4 map + # resolves MaxMind\Db\Reader to the pure-PHP implementation, and the + # suite is written to pass either way -- so an extension that does not + # load leaves every assertion above testing src/ rather than the object + # this job built. + # + # gmp is what turns ReaderTest's decimal uint128 case from + # markTestIncomplete into a real assertion, and markTestIncomplete does + # not fail a run. (The hex form at ReaderTest.php:162 is gated on + # extension_loaded('maxminddb'), not on gmp, so only half the uint128 + # coverage depends on this -- but it is the half that checks the value + # rather than the format.) + - name: Check the extension and gmp are actually loaded + run: | + php -d extension="$PWD/ext/modules/maxminddb.so" -r \ + 'exit(extension_loaded("maxminddb") ? 0 : 1);' || + { echo "::error::the bundled extension did not load; PHPUnit would silently test the pure-PHP reader"; exit 1; } + php -r 'exit(extension_loaded("gmp") ? 0 : 1);' || + { echo "::error::gmp is not loaded; ReaderTest's uint128 value assertions would be skipped, not run"; exit 1; } + + - name: Test with phpunit using the bundled extension + run: php -d extension="$PWD/ext/modules/maxminddb.so" vendor/bin/phpunit + + bundled-debug: + runs-on: ubuntu-latest + container: shivammathur/node:latest-amd64 + + name: "Bundled build with debug flags" + steps: + - name: Install PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + extensions: "mbstring, intl" + tools: "composer, phpize" + + - name: Checkout + # We use v1 due to https://github.com/actions/checkout/issues/334 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + + # --enable-maxminddb-debug adds -Werror, so this shows that the bundled + # libmaxminddb sources build warning free too. + - name: Build extension + run: | + cd ext + phpize + ./configure --with-maxminddb --with-maxminddb-bundled --enable-maxminddb-debug + make clean + make + ../dev-bin/run-ext-tests.sh + + # The debug build differs in flags and codegen, so it is the object most + # likely to pick up something the release build does not -- a RUNPATH + # from a different link line, say. It was the one bundled object the gate + # never saw. + - name: Check that the extension is self-contained + run: dev-bin/gate-extension.sh ext/modules/maxminddb.so + + # Nothing else exercises the gate's failure paths: every other caller + # runs it over an object it expects to pass. See the script's header for + # what it refutes and why. + - name: Check that the gate rejects what it should + run: dev-bin/test-gate-extension.sh ext/modules/maxminddb.so + + windows-bundled: + runs-on: windows-latest + name: "Windows bundled build (x64, nts, PHP 8.4)" + steps: + # The action clones and initialises its own copy to build from; this + # checkout supplies dev-bin/verify-extension.php and the tests/data + # database the gate below queries. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + persist-credentials: false + + # extension-url rather than the action's local mode, which cannot be used + # here: local mode selects itself by finding a config.w32 anywhere in the + # tree, but Get-ExtensionName then reads "config.w32" relative to the + # repository root, so an extension living in ext/ is detected and then + # immediately fails. Cloning also gets the action's own recursive + # submodule init, which local mode does not do. This is the shape + # maxmind/MaxMind-DB-Reader-php-ext already builds this config.w32 with. + # + # One job is enough. config.w32 does not vary by PHP version, arch or + # thread safety in any way this exercises, and PHP <= 7.4 needs a + # toolchain windows-latest no longer carries. + - name: Build the extension + uses: php/php-windows-builder/extension@29352c0ef9e8ce65264ea9e287881a6f7758a953 # 1.9.0 + with: + extension-url: https://github.com/${{ github.repository }} + extension-ref: ${{ github.event.pull_request.head.sha || github.sha }} + build-directory: winbuild + php-version: '8.4' + arch: x64 + ts: nts + args: --with-maxminddb --with-maxminddb-bundled + + # Load-bearing, not belt-and-braces. 001 and 002 skip when the extension + # is not loaded and 003 skips on Windows outright, so every phpt skips + # and run-tests.php exits 0 -- a DLL that builds but cannot load is + # otherwise a green job. That is not hypothetical: a DLL with no + # get_module export shipped from the sibling repository exactly this way. + # + # Uses the php.exe the build downloaded, not the runner's: version, arch, + # thread safety and toolset all have to match. Neither is on PATH in a + # later step, so both are located by search. + - name: Gate the built DLL + shell: pwsh + run: | + $dll = @(Get-ChildItem winbuild -Recurse -File -Filter php_maxminddb.dll) + $php = @(Get-ChildItem winbuild -Recurse -File -Filter php.exe | + Where-Object { $_.Directory.Name -eq 'php-bin' }) + if ($dll.Count -ne 1) { throw "expected one php_maxminddb.dll, found $($dll.Count)" } + if ($php.Count -ne 1) { throw "expected one php-bin\php.exe, found $($php.Count)" } + + $ac = Get-Content ext/libmaxminddb/configure.ac -Raw + if ($ac -notmatch 'AC_INIT\(\[libmaxminddb\], \[([^\]]+)\]') { + throw "could not read the libmaxminddb version from ext/libmaxminddb/configure.ac" + } + $version = $Matches[1] + + # verify-extension.php asserts the extension is loaded, queries a real + # database and compares MMDB_LIB_VERSION -- which also covers the + # version config.w32 scrapes out of the submodule. + & $php[0].FullName -n -d "extension=$($dll[0].FullName)" ` + dev-bin/verify-extension.php ` + tests/data/test-data/GeoIP2-City-Test.mmdb $version + if ($LASTEXITCODE -ne 0) { throw "the DLL did not load and query cleanly" } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd0c1d23..9398b5f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,7 @@ jobs: ./configure --with-maxminddb --enable-maxminddb-debug make clean make - NO_INTERACTION=1 make test + ../dev-bin/run-ext-tests.sh cd .. - name: Install dependencies diff --git a/.gitmodules b/.gitmodules index e8246baa..682d9301 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "tests/data"] path = tests/data url = https://github.com/maxmind/MaxMind-DB +[submodule "ext/libmaxminddb"] + path = ext/libmaxminddb + url = https://github.com/maxmind/libmaxminddb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc4732d..4d238096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ CHANGELOG Jean-Baptiste Nahan. GitHub #231. * Replaced `XtOffsetOf()` with `offsetof()`. The `XtOffsetOf()` alias has been removed in PHP 8.6. Pull request by Remi Collet. GitHub #252. +* The extension can now be built from a bundled copy of libmaxminddb, on + both Unix-like systems and Windows, by passing `--with-maxminddb-bundled` + to `configure` (or to `configure.bat` on Windows). This produces an + extension that does not depend on a system libmaxminddb, which is a + prerequisite for distributing precompiled builds; on Windows it also + replaces the 1.5.0 import library that PHP publishes for Windows builds. + The default is unchanged: without the flag, the extension links against a + system libmaxminddb as before. GitHub #265. 1.13.1 (2025-11-21) ------------------- diff --git a/README.md b/README.md index 12ac0d5c..b6105520 100644 --- a/README.md +++ b/README.md @@ -149,18 +149,39 @@ pecl install maxminddb ### Installing Extension from Source ### -Alternatively, you may install it from the source. To do so, run the following -commands from the top-level directory of this distribution: +Alternatively, you may install it from the source. This links against the +libmaxminddb installed on your system, so install that first as described +above. Run the following commands from the top-level directory of this +distribution: ``` cd ext phpize -./configure +./configure --with-maxminddb make make test sudo make install ``` +#### Building without a system libmaxminddb #### + +Passing `--with-maxminddb-bundled` compiles a bundled copy of libmaxminddb +into the extension instead, so the result depends on nothing but the C +runtime. This is what the precompiled builds use. The bundled sources are a +git submodule, so they have to be checked out first: + +``` +git submodule update --init +cd ext +phpize +./configure --with-maxminddb --with-maxminddb-bundled +make +make test +sudo make install +``` + +The same flag works on Windows, passed to `configure.bat`. + You then must load your extension. The recommended method is to add the following to your `php.ini` file: diff --git a/dev-bin/gate-extension.sh b/dev-bin/gate-extension.sh new file mode 100755 index 00000000..562a02a1 --- /dev/null +++ b/dev-bin/gate-extension.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# +# Refuse to ship an extension object that is not self-contained. +# +# Shared with maxmind/MaxMind-DB-Reader-php-ext, which reaches this file +# through its submodule checkout and gates the objects it publishes with it. +# One implementation is the only way the bar cannot drift between the two. +# +# Every tool runs in its own assignment and the greps read captured output, +# never a live pipe: `set -e` is suspended inside an `if` condition, so +# `if some-tool "$so" | grep ...` cannot tell "no match" from "not installed". +# For the same reason an unmeasurable result -- an empty NEEDED list, glibc +# floor or minos -- is fatal rather than a pass. +# +# Usage: gate-extension.sh +# +# Reads MAX_GLIBC on Linux. The two repositories that run this set different +# values -- they build in different containers -- so the limit belongs at the +# call site. MACOSX_DEPLOYMENT_TARGET is its macOS counterpart and is set only +# by the extension repository; nothing here reaches the Darwin branch. + +set -euo pipefail + +fail() { + echo "::error::$*" + exit 1 +} + +# Before "$1" is dereferenced: under `set -u` a bare `so="$1"` would abort with +# bash's own message instead of this one. +[ $# -eq 1 ] || fail "Usage: gate-extension.sh " + +so="$1" + +# True when $2 is no higher a version than $1. Both operands are padded to the +# same component count first, so `at_most 11 11.0` is not read as greater. +at_most() { + local a="$1" b="$2" + # An empty operand would compare as "no higher" and waive the check. + [ -n "$a" ] || fail "at_most called with an empty ceiling." + [ -n "$b" ] || fail "at_most called with an empty measurement." + while [ "$(awk -F. '{print NF}' <<<"$a")" -lt "$(awk -F. '{print NF}' <<<"$b")" ]; do a="$a.0"; done + while [ "$(awk -F. '{print NF}' <<<"$b")" -lt "$(awk -F. '{print NF}' <<<"$a")" ]; do b="$b.0"; done + [ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -n1)" = "$a" ] +} + +[ -f "$so" ] || fail "$so does not exist or is not a regular file." + +# Checks 3, 3b and 3c. They differ between platforms only in the nm invocation +# and Mach-O's leading underscore, so the caller sets $undefined and $defined +# and passes the prefix. One copy rather than two because the platforms differ +# in the tool invocation, not in what is being asserted. +# +# The exported-symbol check exists because vendoring makes libmaxminddb's API part of this object's +# exports, and PHP dlopens extensions with RTLD_GLOBAL, so a process that also +# loads a system libmaxminddb could bind across the two. config.m4 passes +# -fvisibility=hidden to prevent it; this asserts that worked. MSVC exports +# nothing unmarked, so config.w32 needs no equivalent. +# +# The get_module check is its counterweight: -fvisibility=hidden covers our own maxminddb.c too, +# and get_module survives only because ZEND_GET_MODULE expands through +# ZEND_DLEXPORT, which carries visibility("default"). +check_symbols() { + local prefix="$1" exported + + if grep -E "(^|[[:space:]])${prefix}MMDB_" <<<"$undefined"; then + fail "Undefined MMDB_ symbols remain." + fi + + exported="$(grep -E "(^|[[:space:]])${prefix}MMDB_" <<<"$defined" || true)" + if [ -n "$exported" ]; then + printf '%s\n' "$exported" + fail "The object exports libmaxminddb's MMDB_ symbols; they should be hidden." + fi + + if ! grep -qE "(^|[[:space:]])${prefix}get_module$" <<<"$defined"; then + fail "The object does not export get_module; PHP will reject it as not a PHP library." + fi +} + + +# Informational only; file(1) ships separately from binutils. +file "$so" || echo "file(1) is unavailable; skipping the object summary." + +case "$(uname -s)" in +Linux) + # Checked here rather than left to `set -u`, so a caller that forgets it + # gets this script's annotation like every other failure. + [ -n "${MAX_GLIBC:-}" ] || fail "MAX_GLIBC is not set; the glibc ceiling has no value to compare against." + + dynamic="$(readelf -d "$so")" + + # 1. No libmaxminddb dependency -- the point of the bundled build is that + # users need nothing but libc. The parenthesis is optional because GNU + # readelf prints `(NEEDED)` and llvm-readelf a bare `NEEDED`. + needed="$(sed -n 's/.*(\{0,1\}NEEDED)\{0,1\}.*\[\(.*\)\]/\1/p' <<<"$dynamic")" + printf 'NEEDED:\n%s\n' "$needed" + # An extension object always links libc, so an empty list here means the + # parse failed rather than that there are no dependencies. (A trivial + # gcc -shared object genuinely has none, but that is not what we gate.) + if [ -z "$needed" ]; then + fail "Could not read any DT_NEEDED entries from $so; the gate proved nothing." + fi + if grep -qi maxminddb <<<"$needed"; then + fail "The object still links libmaxminddb." + fi + + # 2. No RUNPATH/RPATH -- a search path baked in from the build container is + # meaningless, or worse, on a user's machine. + if grep -E '\(?(RUNPATH|RPATH)\)?' <<<"$dynamic"; then + fail "The object carries a RUNPATH/RPATH." + fi + + # 3. The symbol checks: no undefined MMDB_, no exported MMDB_, get_module + # still exported. See check_symbols above. + undefined="$(nm -D -u "$so")" + defined="$(nm -D --defined-only "$so")" + check_symbols '' + + # 4. Nothing but the C runtime. Check 1 rejects libmaxminddb by name; this + # makes any other new dependency a deliberate decision. libatomic turns + # up for 64-bit atomics on some 32-bit architectures. + # + # No musl spellings: check 4 below requires a measurable GLIBC_x.y floor + # and treats its absence as fatal, so a musl-linked object cannot reach + # this list. Allowlisting one would only look like support. + while read -r lib; do + [ -n "$lib" ] || continue + case "$lib" in + libc.so.* | libm.so.* | libdl.so.* | librt.so.* | libpthread.so.* | \ + ld-linux*.so.* | libgcc_s.so.* | libatomic.so.*) ;; + *) fail "Unexpected runtime dependency $lib; the object should need nothing but the C runtime." ;; + esac + done <<<"$needed" + + # 5. Measured glibc floor must not exceed the documented maximum. The + # `|| true` is scoped to the grep, which legitimately exits non-zero on + # no match; wrapping the whole pipeline would swallow a sed, sort or + # tail failure too, and a partial result yields a floor lower than the + # real one, which passes the ceiling check. + syms="$(objdump -T "$so")" + # Unstable at any version, so an absolute bar rather than part of the floor. + if grep -qE 'GLIBC_PRIVATE' <<<"$syms"; then + fail "The object references GLIBC_PRIVATE, which is not a stable interface." + fi + floor="$({ grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' <<<"$syms" || true; } | sed 's/^GLIBC_//' | sort -uV | tail -n1)" + if [ -z "$floor" ]; then + fail "Could not measure a glibc floor for $so; the gate proved nothing." + fi + # GLIBC_ABI_DT_RELR carries no version, so the pattern above cannot see it, + # yet it needs glibc >= 2.36. Raise the floor rather than reject: requiring + # 2.36 is only wrong against a lower ceiling. Ordered after the empty-floor + # guard so it cannot paper over a failed measurement. + if grep -qE 'GLIBC_ABI_DT_RELR' <<<"$syms"; then + echo "The object requires GLIBC_ABI_DT_RELR, so its floor is at least 2.36." + floor="$(printf '%s\n2.36\n' "$floor" | sort -uV | tail -n1)" + fi + echo "Measured glibc floor: $floor (documented maximum $MAX_GLIBC)" + if ! at_most "$MAX_GLIBC" "$floor"; then + fail "Requires glibc $floor, above the documented maximum $MAX_GLIBC." + fi + ;; +Darwin) + [ -n "${MACOSX_DEPLOYMENT_TARGET:-}" ] || fail "MACOSX_DEPLOYMENT_TARGET is not set; the deployment-target ceiling has no value to compare against." + + # Reached only from the extension repository's macOS lane; the + # bundled-build workflow here is Linux-only. + linked="$(otool -L "$so")" + loadcmds="$(otool -l "$so")" + undefined="$(nm -u "$so")" + + # 1. No libmaxminddb dependency. tail -n +2 drops otool's echo of the + # object's own path. + printf '%s\n' "$linked" + if tail -n +2 <<<"$linked" | grep -i maxminddb; then + fail "The object still links libmaxminddb." + fi + + # 2. No LC_RPATH: a Homebrew prefix baked in here would not exist on a + # user's machine. + if grep -A3 LC_RPATH <<<"$loadcmds"; then + fail "The object carries an LC_RPATH." + fi + + # 3. The symbol checks, with Mach-O's leading underscore. + # `nm -gU` (external only, defined only) means the same to Apple's nm and + # to the llvm-nm now behind it. + defined="$(nm -gU "$so")" + check_symbols _ + + # 4. Nothing but libSystem, the Mach-O counterpart of the NEEDED + # allowlist. Only indented lines whose first field is an absolute path + # are dependencies, which skips otool's echo of the object's own path + # and, on a universal binary, the architecture headers. + dylibs="$(awk '/^[[:space:]]+\// {print $1}' <<<"$linked")" + if [ -z "$dylibs" ]; then + fail "Could not read any linked dylibs from $so; the gate proved nothing." + fi + while read -r lib; do + [ -n "$lib" ] || continue + case "$lib" in + /usr/lib/libSystem.B.dylib | /usr/lib/system/*) ;; + *) fail "Unexpected runtime dependency $lib; the object should need nothing but libSystem." ;; + esac + done <<<"$dylibs" + + # 5. The macOS analogue of the glibc floor. Every slice is measured, not + # just the first: otool -l emits load commands per architecture, arm64 + # has a hard 11.0 floor, and Xcode clamps minos per architecture, so an + # x86_64 slice at 11.0 can hide an arm64 slice at 14.0. + # A slice targeting 10.13 or lower carries LC_VERSION_MIN_MACOSX instead, + # which the awk below cannot see. On a universal object mixing the two the + # empty-result guard never fires -- all_minos is non-empty from the modern + # slices -- and the old-style one is silently unmeasured, so reject it + # outright rather than measuring around it. + if grep -q LC_VERSION_MIN_MACOSX <<<"$loadcmds"; then + fail "$so carries LC_VERSION_MIN_MACOSX; its deployment target cannot be measured here." + fi + all_minos="$(awk '/LC_BUILD_VERSION/{f=1} f && $1=="minos"{print $2; f=0}' <<<"$loadcmds")" + minos="$(sort -V <<<"$all_minos" | tail -n1)" + printf 'Measured minimum macOS per slice: %s (documented maximum %s)\n' \ + "${all_minos:-none}" "$MACOSX_DEPLOYMENT_TARGET" + if [ -z "$minos" ]; then + # A target of 10.13 or lower emits LC_VERSION_MIN_MACOSX instead. + fail "Could not read an LC_BUILD_VERSION minos from $so; the deployment target is unverified. An object targeting 10.13 or lower carries LC_VERSION_MIN_MACOSX instead." + fi + if ! at_most "$MACOSX_DEPLOYMENT_TARGET" "$minos"; then + fail "Requires macOS $minos, above the documented maximum $MACOSX_DEPLOYMENT_TARGET." + fi + ;; +*) + fail "No gate implemented for $(uname -s)." + ;; +esac diff --git a/dev-bin/run-ext-tests.sh b/dev-bin/run-ext-tests.sh new file mode 100755 index 00000000..0b10427d --- /dev/null +++ b/dev-bin/run-ext-tests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Run the extension's phpt suite and assert it ran and passed. +# +# `make test` cannot be trusted on its own here. The Makefile phpize generates +# ends its `test` target with an echo and no `exit 1`, so it succeeds having +# executed nothing when there is no CLI sapi; on 7.2 and 7.3 it does not +# propagate run-tests.php's status at all. run-tests.php in turn counts SKIPPED +# as a pass, and the phpt files that check the extension skip when it is not +# loaded -- so a build producing an unloadable object could otherwise go green. +# +# Run from the directory holding the generated Makefile (ext/). +# +# Shared with maxmind/MaxMind-DB-Reader-php-ext, which builds the same +# extension and needs the same assertion, for the reason dev-bin/ +# gate-extension.sh gives for sharing the gate. + +set -euo pipefail + +fail() { + echo "::error::$*" + exit 1 +} + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT + +NO_INTERACTION=1 make test 2>&1 | tee "$log" + +# Reads one count out of run-tests.php's summary block. Empty means the line is +# missing, which is a parse failure rather than a zero. +count() { sed -n "s/^Tests $1 *: *\([0-9]*\).*/\1/p" "$log" | tail -n1; } + +if grep -q "Cannot run tests without CLI sapi" "$log"; then + fail "make test ran no tests: no CLI sapi was found" +fi +if ! grep -qE "Tests +(passed|failed)" "$log"; then + fail "make test produced no test summary; it likely ran nothing" +fi + +passed="$(count passed)" +failed="$(count failed)" +warned="$(count warned)" +[ -n "$passed" ] && [ -n "$failed" ] && [ -n "$warned" ] || + fail "could not read the pass/fail/warn counts from the make test summary" + +# Failures are what matters, and `make test` will not report them for us: the +# summary prints regardless, and the exit status is unreliable on 7.2 and 7.3. +[ "$failed" -eq 0 ] || fail "make test had $failed failing test(s)." +[ "$warned" -eq 0 ] || fail "make test had $warned warned test(s)." +# And a run where everything skipped is not a pass. +[ "$passed" -ge 1 ] || + fail "make test passed $passed tests; the extension probably did not load" + +echo "make test passed $passed tests, $failed failed, $warned warned." diff --git a/dev-bin/test-gate-extension.sh b/dev-bin/test-gate-extension.sh new file mode 100755 index 00000000..2220b12c --- /dev/null +++ b/dev-bin/test-gate-extension.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# +# Assert that gate-extension.sh accepts a good object and rejects bad ones. +# +# Every other caller runs the gate over an object it expects to pass, so the +# gate is only ever observed succeeding. Nothing would notice it breaking into +# certifying anything -- or, just as quietly, into refusing everything, which +# a suite of rejection cases alone cannot tell apart from working correctly. +# +# So: a positive control first, and every rejection matched on the message the +# gate is supposed to print rather than on a non-zero exit, since 126 and 127 +# are non-zero too and "the gate never ran" must not read as "the gate said no". +# +# Usage: test-gate-extension.sh + +set -euo pipefail + +fail() { + echo "::error::$*" + exit 1 +} + +[ $# -eq 1 ] || fail "Usage: test-gate-extension.sh " + +so="$1" +[ -f "$so" ] || fail "$so does not exist; nothing to test the gate against." + +gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/gate-extension.sh" +[ -x "$gate" ] || fail "$gate is missing or not executable." + +command -v gcc >/dev/null || fail "gcc is required to build the fixtures this test refutes against." + +# The positive control. Without it, a gate broken into always failing satisfies +# every case below. +"$gate" "$so" >/dev/null || fail "the gate rejected the known-good object $so." + +refute() { # + local want="$1" what="$2" out status + shift 2 + set +e + out="$("$@" 2>&1)" + status=$? + set -e + [ "$status" -ne 0 ] || fail "the gate accepted $what" + grep -qF "$want" <<<"$out" || fail \ + "the gate rejected $what, but not for the expected reason: wanted \"$want\", got \"$(grep -m1 '::error::' <<<"$out" || head -n1 <<<"$out")\"" +} + +# Fixtures for the checks no malformed *input* can reach -- they need an object +# that builds and loads but is wrong in one specific way. Each carries a +# get_module and a libc reference so it clears the earlier checks and reaches +# the one under test. +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +cat > "$work/base.c" <<'C' +#include +__attribute__((visibility("default"))) void *get_module(void) { return malloc(8); } +C +build() { # + local out="$1" src="$2" + shift 2 + cat "$work/base.c" > "$work/tmp.c" + [ -z "$src" ] || printf '%s\n' "$src" >> "$work/tmp.c" + gcc -shared -fPIC -fvisibility=hidden -o "$work/$out" "$work/tmp.c" "$@" +} + +build exports.so '__attribute__((visibility("default"))) int MMDB_open(void) { return 0; }' +build nogetmodule.so '' -Wl,--version-script=/dev/null 2>/dev/null || + gcc -shared -fPIC -fvisibility=hidden -o "$work/nogetmodule.so" \ + -xc - <<<'#include +void *get_module(void) { return malloc(8); }' +build runpath.so '' -Wl,--enable-new-dtags,-rpath,/tmp +# Something outside the C runtime to depend on, built here so the test needs no +# development packages installed. +gcc -shared -fPIC -o "$work/libunexpected.so" -xc - <<<'int unexpected(void) { return 0; }' +build extradep.so 'extern int unexpected(void); int use(void) { return unexpected(); }' \ + -L"$work" -lunexpected -Wl,-rpath-link,"$work" + +refute "Usage: gate-extension.sh" "a missing argument" "$gate" +refute "does not exist or is not a regular file" "a path that does not exist" \ + "$gate" /nonexistent/maxminddb.so +refute "above the documented maximum" "an object above its glibc ceiling" \ + env MAX_GLIBC=2.0 "$gate" "$so" +refute "exports libmaxminddb's MMDB_ symbols" "an object exporting MMDB_ symbols" \ + "$gate" "$work/exports.so" +refute "does not export get_module" "an object with get_module hidden" \ + "$gate" "$work/nogetmodule.so" +refute "carries a RUNPATH" "an object with a RUNPATH" "$gate" "$work/runpath.so" +refute "Unexpected runtime dependency" "an object with a non-libc dependency" \ + "$gate" "$work/extradep.so" + +# The one case with no ::error:: to match: readelf aborts on a non-object before +# any check runs, which is the correct outcome but not one the gate announces. +if "$gate" /etc/hostname >/dev/null 2>&1; then + fail "the gate accepted a file that is not an object" +fi + +echo "the gate accepted the good object and rejected all eight bad ones." diff --git a/dev-bin/verify-extension.php b/dev-bin/verify-extension.php new file mode 100644 index 00000000..b496c2b4 --- /dev/null +++ b/dev-bin/verify-extension.php @@ -0,0 +1,66 @@ +` so that no ini +// file can supply anything the object did not bring with it. +// +// Usage: php verify-extension.php +// +// Shared with maxmind/MaxMind-DB-Reader-php-ext, which needs the same check +// against the binaries it publishes. + +// Validated rather than assumed: this script is shared with +// maxmind/MaxMind-DB-Reader-php-ext, where the caller is out of sight. An empty +// $expected against an empty MMDB_LIB_VERSION would compare equal and exit 0 +// having proved nothing -- and "" is exactly the value a PACKAGE_VERSION that +// never reached the compiler produces. +if ($argc !== 3) { + fwrite(\STDERR, "usage: verify-extension.php \n"); + + exit(1); +} +if ($argv[2] === '') { + fwrite(\STDERR, "the expected MMDB_LIB_VERSION must not be empty\n"); + + exit(1); +} + +// Does not rely on the caller passing -n: with an autoloader in scope the +// pure-PHP Reader would satisfy everything below and prove nothing about the +// object under test. +if (!extension_loaded('maxminddb')) { + fwrite(\STDERR, "the maxminddb extension is not loaded\n"); + + exit(1); +} + +$reader = new Reader($argv[1]); +$record = $reader->get('81.2.69.160'); +$city = isset($record['city']['names']['en']) + ? $record['city']['names']['en'] : ''; + +// Asserting the value rather than merely that one came back: 81.2.69.160 is +// London in GeoIP2-City-Test.mmdb, so this is a correctness check for one +// decoded string instead of a liveness check. +if ($city !== 'London') { + fwrite(\STDERR, 'expected London, got: ' . var_export($city, true) . "\n"); + + exit(1); +} +echo "lookup returned city: {$city}\n"; + +// MMDB_lib_version() returns the PACKAGE_VERSION the build system defined, so +// this catches a submodule bump that did not update it -- and, on PHP 7.2 and +// 7.3, a define that never reached the compiler at all. +$expected = $argv[2]; +$actual = Reader::MMDB_LIB_VERSION; +if ($actual !== $expected) { + fwrite(\STDERR, "MMDB_LIB_VERSION is {$actual}, expected {$expected}\n"); + + exit(1); +} +echo "MMDB_LIB_VERSION: {$actual}\n"; diff --git a/ext/bundled-include/maxminddb_config.h b/ext/bundled-include/maxminddb_config.h new file mode 100644 index 00000000..c578a4eb --- /dev/null +++ b/ext/bundled-include/maxminddb_config.h @@ -0,0 +1,37 @@ +#ifndef MAXMINDDB_CONFIG_H +#define MAXMINDDB_CONFIG_H + +/* Supplies the header libmaxminddb's maxminddb.h includes unconditionally and + * that libmaxminddb's own build system would generate. We never run that + * build. + * + * This is the canonical account of the arrangement; ext/config.m4 and + * ext/config.w32 point here rather than repeating it. + * + * It is a tracked file rather than something the build systems write at + * configure time: generating it meant writing inside the submodule on Windows, + * which left it dirty after every build. Nothing includes anything from the + * submodule root, so that directory is deliberately off the include path. + * + * PACKAGE_VERSION lives here rather than on the command line. It has to reach + * libmaxminddb's sources, which is what MMDB_lib_version() returns, but our own + * ext/maxminddb.c also includes php.h -- and PHP only began stripping PACKAGE_* + * from its generated headers in 7.4, so a -D on the global CFLAGS redefines + * PHP's macro on 7.2 and 7.3. That is a warning today and a build failure under + * --enable-maxminddb-debug, which adds -Werror. + * + * The #ifndef makes this a no-op wherever PACKAGE_VERSION is already defined: + * ext/maxminddb.c includes php.h before maxminddb.h, so it keeps PHP's value + * and never sees a redefinition -- harmless, because it reads MMDB_lib_version() + * rather than the macro. config.w32 passes its own /D, read out of the + * submodule's configure.ac, which likewise wins over this default. + * + * Keep the version in step with the submodule. test-bundled.yml's "Load the + * extension on its own" step compares MMDB_LIB_VERSION against configure.ac on + * every build, so a stale value here fails CI rather than shipping. + */ +#ifndef PACKAGE_VERSION +#define PACKAGE_VERSION "1.13.3" +#endif + +#endif /* MAXMINDDB_CONFIG_H */ diff --git a/ext/config.m4 b/ext/config.m4 index c09151e4..e4357e63 100644 --- a/ext/config.m4 +++ b/ext/config.m4 @@ -2,32 +2,104 @@ PHP_ARG_WITH(maxminddb, [Whether to enable the MaxMind DB Reader extension], [ --with-maxminddb Enable MaxMind DB Reader extension support]) +PHP_ARG_WITH(maxminddb-bundled, + [Whether to build the bundled libmaxminddb sources into the extension], + [ --with-maxminddb-bundled Build the bundled libmaxminddb sources into the + extension instead of linking a system library], no, no) + PHP_ARG_ENABLE(maxminddb-debug, for MaxMind DB debug support, [ --enable-maxminddb-debug Enable MaxMind DB debug support], no, no) +dnl --with-maxminddb-bundled on its own is otherwise a silent no-op: the whole +dnl block below is skipped, configure exits 0, and make builds nothing. +if test "$PHP_MAXMINDDB_BUNDLED" != "no" && test "$PHP_MAXMINDDB" = "no"; then + AC_MSG_ERROR([--with-maxminddb-bundled requires --with-maxminddb]) +fi + if test $PHP_MAXMINDDB != "no"; then - AC_PATH_PROG(PKG_CONFIG, pkg-config, no) + maxminddb_sources="maxminddb.c" - AC_MSG_CHECKING(for libmaxminddb) - if test -x "$PKG_CONFIG" && $PKG_CONFIG --exists libmaxminddb; then - dnl retrieve build options from pkg-config - if $PKG_CONFIG libmaxminddb --atleast-version 1.0.0; then - LIBMAXMINDDB_INC=`$PKG_CONFIG libmaxminddb --cflags` - LIBMAXMINDDB_LIB=`$PKG_CONFIG libmaxminddb --libs` - LIBMAXMINDDB_VER=`$PKG_CONFIG libmaxminddb --modversion` - AC_MSG_RESULT(found version $LIBMAXMINDDB_VER) - else - AC_MSG_ERROR(system libmaxminddb must be upgraded to version >= 1.0.0) - fi - PHP_EVAL_LIBLINE($LIBMAXMINDDB_LIB, MAXMINDDB_SHARED_LIBADD) - PHP_EVAL_INCLINE($LIBMAXMINDDB_INC) + if test "$PHP_MAXMINDDB_BUNDLED" != "no"; then + dnl The arguments are [if-big], [if-little], [if-unknown], [if-universal], + dnl and only the first two define the macro, so it is left undefined + dnl unless the answer is actually known. + dnl + dnl Guessing here is not safe. The macro is consumed only by the + dnl byte-swaps in get_ieee754_float() and get_ieee754_double(), so a + dnl wrong value builds, links and loads perfectly well and then returns + dnl garbage for every float and double -- latitude, longitude, + dnl accuracyRadius -- while strings and integers stay correct. + dnl + dnl Under a universal (multi -arch) build no single configure-time value + dnl can be right for every slice. In both cases libmaxminddb's own + dnl maxminddb.h derives it from __BYTE_ORDER__ behind + dnl `#if !defined(MMDB_LITTLE_ENDIAN)`, which is per-architecture correct + dnl and is what we want to fall through to. + dnl + dnl That fall-through relies on __BYTE_ORDER__, which every compiler + dnl able to build PHP defines, and maxminddb.h covers Windows + dnl separately. Where it is genuinely absent the macro stays undefined, + dnl `#if MMDB_LITTLE_ENDIAN` evaluates it as 0, and the build assumes + dnl big-endian silently -- correct on a big-endian target and wrong on a + dnl little-endian one. Upstream aborts instead, having no universal + dnl build to accommodate. Compiling with -Wundef surfaces exactly this + dnl case; it is not added here because PHP's own headers do not build + dnl warning-free under it, and --enable-maxminddb-debug turns warnings + dnl into errors. + dnl + dnl The third action must be non-empty: autoconf treats an empty + dnl argument as absent, and its default for action-if-unknown is to + dnl abort rather than fall through. The fourth is passed for symmetry; + dnl its default is a harmless AC_DEFINE libmaxminddb never reads. + m4_define([_mmdb_endian_unknown], + [AC_MSG_NOTICE([endianness undetermined; letting maxminddb.h derive MMDB_LITTLE_ENDIAN from __BYTE_ORDER__])]) + AC_C_BIGENDIAN([CFLAGS="$CFLAGS -DMMDB_LITTLE_ENDIAN=0"], + [CFLAGS="$CFLAGS -DMMDB_LITTLE_ENDIAN=1"], + [_mmdb_endian_unknown], [_mmdb_endian_unknown]) + + dnl -fvisibility=hidden keeps libmaxminddb's MMDB_* API from becoming + dnl part of this object's export table. Vendoring turns those from + dnl someone else's exports into ours, and PHP dlopens extensions with + dnl RTLD_GLOBAL on common builds, so a process that also loads + dnl something linked against a system libmaxminddb could bind across + dnl the two. dev-bin/gate-extension.sh asserts the result. + dnl + dnl -UHAVE_CONFIG_H must precede -DHAVE_CONFIG_H=0: PHP's CPPFLAGS define + dnl HAVE_CONFIG_H, and redefining it is a warning that becomes an error + dnl under --enable-maxminddb-debug, which adds -Werror. + dnl + dnl PACKAGE_VERSION is deliberately absent: it is defined in + dnl ext/bundled-include/maxminddb_config.h instead, because a -D here + dnl also reaches ext/maxminddb.c, which includes php.h -- and PHP only + dnl began stripping PACKAGE_* from its generated headers in 7.4, so on + dnl 7.2 and 7.3 this redefined PHP's own macro. See that header. + CFLAGS="$CFLAGS -fvisibility=hidden -UHAVE_CONFIG_H -DHAVE_CONFIG_H=0 -DMMDB_UINT128_USING_MODE=0 -DMMDB_UINT128_IS_BYTE_ARRAY=1" + + maxminddb_sources="$maxminddb_sources libmaxminddb/src/maxminddb.c libmaxminddb/src/data-pool.c" else - AC_MSG_RESULT(pkg-config information missing) - AC_MSG_WARN(will use libmaxmxinddb from compiler default path) + AC_PATH_PROG(PKG_CONFIG, pkg-config, no) - PHP_CHECK_LIBRARY(maxminddb, MMDB_open) - PHP_ADD_LIBRARY(maxminddb, 1, MAXMINDDB_SHARED_LIBADD) + AC_MSG_CHECKING(for libmaxminddb) + if test -x "$PKG_CONFIG" && $PKG_CONFIG --exists libmaxminddb; then + dnl retrieve build options from pkg-config + if $PKG_CONFIG libmaxminddb --atleast-version 1.0.0; then + LIBMAXMINDDB_INC=`$PKG_CONFIG libmaxminddb --cflags` + LIBMAXMINDDB_LIB=`$PKG_CONFIG libmaxminddb --libs` + LIBMAXMINDDB_VER=`$PKG_CONFIG libmaxminddb --modversion` + AC_MSG_RESULT(found version $LIBMAXMINDDB_VER) + else + AC_MSG_ERROR(system libmaxminddb must be upgraded to version >= 1.0.0) + fi + PHP_EVAL_LIBLINE($LIBMAXMINDDB_LIB, MAXMINDDB_SHARED_LIBADD) + PHP_EVAL_INCLINE($LIBMAXMINDDB_INC) + else + AC_MSG_RESULT(pkg-config information missing) + AC_MSG_WARN(will use libmaxmxinddb from compiler default path) + + PHP_CHECK_LIBRARY(maxminddb, MMDB_open) + PHP_ADD_LIBRARY(maxminddb, 1, MAXMINDDB_SHARED_LIBADD) + fi fi if test $PHP_MAXMINDDB_DEBUG != "no"; then @@ -36,5 +108,28 @@ if test $PHP_MAXMINDDB != "no"; then PHP_SUBST(MAXMINDDB_SHARED_LIBADD) - PHP_NEW_EXTENSION(maxminddb, maxminddb.c, $ext_shared) + PHP_NEW_EXTENSION(maxminddb, $maxminddb_sources, $ext_shared) + + dnl These have to come after PHP_NEW_EXTENSION, which is what defines + dnl $ext_srcdir and $ext_builddir. Without the build directory, the object + dnl directory for the bundled sources is never created and they fail to + dnl compile. + if test "$PHP_MAXMINDDB_BUNDLED" != "no"; then + dnl A clone without --recursive otherwise produces a stray "No such + dnl file or directory", a *successful* configure -- autoconf-generated + dnl configure does not run under set -e -- and then an opaque failure + dnl much later at maxminddb.h. + if test ! -f "$ext_srcdir/libmaxminddb/src/maxminddb.c"; then + AC_MSG_ERROR([--with-maxminddb-bundled needs the bundled libmaxminddb sources; run "git submodule update --init"]) + fi + + dnl ext/bundled-include supplies maxminddb_config.h, which + dnl libmaxminddb's maxminddb.h includes unconditionally and its own + dnl build system would generate. Nothing is included from the submodule + dnl root, so that directory is deliberately not on the path. + PHP_ADD_BUILD_DIR([$ext_builddir/libmaxminddb/src]) + PHP_ADD_INCLUDE([$ext_srcdir/bundled-include]) + PHP_ADD_INCLUDE([$ext_srcdir/libmaxminddb/include]) + PHP_ADD_INCLUDE([$ext_srcdir/libmaxminddb/src]) + fi fi diff --git a/ext/config.w32 b/ext/config.w32 index dc3e3cad..29b4bc54 100644 --- a/ext/config.w32 +++ b/ext/config.w32 @@ -1,7 +1,114 @@ ARG_WITH("maxminddb", "Enable MaxMind DB Reader extension support", "no"); +ARG_WITH("maxminddb-bundled", "Build the bundled libmaxminddb sources into the extension instead of linking a system library", "no"); if (PHP_MAXMINDDB == "yes") { - if (CHECK_HEADER_ADD_INCLUDE("maxminddb.h", "CFLAGS_MAXMINDDB", PHP_MAXMINDDB + ";" + PHP_PHP_BUILD + "\\include\\maxminddb") && + if (PHP_MAXMINDDB_BUNDLED != "no") { + var maxminddb_bundled = configure_module_dirname + "\\libmaxminddb"; + + /* The file, not just the directory: an empty or half-populated leftover + * src\\ passes a folder check and then fails opaquely at compile time, + * which is what the matching check in config.m4 was added to avoid. */ + if (!FSO.FileExists(maxminddb_bundled + "\\src\\maxminddb.c")) { + ERROR("--with-maxminddb-bundled needs the bundled libmaxminddb sources; run \"git submodule update --init\""); + } + + /* Read the version out of the submodule rather than repeating it here. + * Nothing else checks this file -- there is no Windows workflow in this + * repository -- so a bump that updated config.m4 and forgot this line + * would ship a PACKAGE_VERSION that lies, with nothing to notice it. + * config.m4 keeps a literal because test-bundled.yml compares + * MMDB_LIB_VERSION against configure.ac on every build. */ + var maxminddb_ac = file_get_contents(maxminddb_bundled + "\\configure.ac"); + /* The match is captured rather than read back out of RegExp.$1, which is + * engine-global state shared with every other config.w32 evaluated in + * the same scope. */ + var maxminddb_match = maxminddb_ac + ? maxminddb_ac.match(/AC_INIT\(\[libmaxminddb\], \[([^\]]+)\]/) + : null; + if (!maxminddb_match) { + ERROR("could not read the libmaxminddb version from ext/libmaxminddb/configure.ac"); + } + var maxminddb_version = maxminddb_match[1]; + + /* ext/bundled-include supplies maxminddb_config.h, which + * libmaxminddb's maxminddb.h includes unconditionally and its own build + * system would generate. It is a tracked file: writing it at configure + * time meant writing into the submodule, which left it dirty after + * every build. Nothing is included from the submodule root, so that + * directory is deliberately not on the path. */ + ADD_FLAG("CFLAGS_MAXMINDDB", '/I "' + configure_module_dirname + '\\bundled-include" ' + + '/I "' + maxminddb_bundled + '\\include" ' + + '/I "' + maxminddb_bundled + '\\src"'); + + /* The uint128 defines match ext/config.m4. Three things deliberately do + * not: HAVE_CONFIG_H is left alone here (below); MMDB_LITTLE_ENDIAN is + * hardcoded because every Windows target is little-endian, where + * config.m4 derives it and may leave it undefined; and there is no + * -fvisibility=hidden counterpart, because MSVC exports nothing that is + * not marked __declspec(dllexport). + * config.m4 has to define it as 0 because PHP's Unix CPPFLAGS already + * define it, and libmaxminddb's maxminddb.c tests the *value* with + * `#if HAVE_CONFIG_H`, so it would otherwise include the config.h it + * finds on the include path -- PHP's. Our own maxminddb.c tests + * *definedness* with `#ifdef`, which is why =0 suppresses libmaxminddb's + * include without suppressing ours. That asymmetry is the hinge of this + * design and would break if upstream ever switched to `#ifdef`. + * + * The Windows build never defines HAVE_CONFIG_H, so libmaxminddb + * already does the right thing, and defining it here would instead + * make our own maxminddb.c include a config.h that does not exist. + * + * MSVC has no unsigned __int128, so the byte array is the only option + * for uint128 values. Both this file and config.m4 have to define + * MMDB_UINT128_IS_BYTE_ARRAY the same way, because it decides the + * layout of the MMDB_entry_data_s union that maxminddb.c and the + * bundled sources pass between each other. + * + * MMDB_lib_version() returns PACKAGE_VERSION, and the extension exposes + * that as MMDB_LIB_VERSION and through phpinfo(). The inner quotes are + * escaped so that they survive the compiler's own command line parsing + * and PACKAGE_VERSION ends up a string literal. */ + ADD_FLAG("CFLAGS_MAXMINDDB", '/D MMDB_LITTLE_ENDIAN=1 /D MMDB_UINT128_USING_MODE=0 ' + + '/D MMDB_UINT128_IS_BYTE_ARRAY=1 /D PACKAGE_VERSION=\\"' + maxminddb_version + '\\"'); + + /* MaxMind's Python reader passes -wd4068 for these same sources. + * libmaxminddb guards its #pragma clang lines with __clang__, so cl + * should not warn about them, but keeping the suppression costs nothing + * and survives a bump that drops a guard. */ + ADD_FLAG("CFLAGS_MAXMINDDB", "/wd4068"); + + /* MMDB_open() calls WSAStartup() and maxminddb.h pulls in winsock2.h. + * php-src already has ws2_32.lib in $(LIBS), but sapi/cli and sapi/cgi + * ask for it explicitly too, and the dependency is ours now. */ + ADD_FLAG("LIBS_MAXMINDDB", "ws2_32.lib"); + + EXTENSION("maxminddb", "maxminddb.c"); + + /* This has to come after EXTENSION, which is what makes CFLAGS_MAXMINDDB + * reach the compiler calls that ADD_SOURCES writes. + * + * The fourth argument is the object directory, and it is not optional + * here: libmaxminddb/src/maxminddb.c and our own maxminddb.c share a + * basename. In a php-src tree ADD_SOURCES derives the object directory + * from its first argument, so the two would land in separate + * directories and nothing would collide -- that is how ext/gd gets away + * with libgd/gd.c next to gd.c. Under phpize, which is how this + * extension is built for Windows, confutils.js takes a different branch + * in ADD_SOURCES that derives the build directory from the dirname of + * each file_list entry and ignores the fourth argument. Our entries are + * bare filenames, so both sources resolve to + * $(BUILD_DIR)\maxminddb.obj, the makefile gets two rules for one + * target, and whichever compiles last wins. When libmaxminddb's won, + * the DLL linked without our get_module() and PHP rejected it with + * "Invalid library (maybe not a PHP library)" -- a build that succeeds + * and produces an extension that cannot load. + * + * Passing the object directory explicitly takes the branch that honours + * it, so these objects land in $(BUILD_DIR)\libmaxminddb. ADD_SOURCES + * registers that in build_dirs and configure creates it. */ + ADD_SOURCES(maxminddb_bundled + "\\src", "maxminddb.c data-pool.c", + "maxminddb", "libmaxminddb"); + } else if (CHECK_HEADER_ADD_INCLUDE("maxminddb.h", "CFLAGS_MAXMINDDB", PHP_MAXMINDDB + ";" + PHP_PHP_BUILD + "\\include\\maxminddb") && CHECK_LIB("libmaxminddb.lib;maxminddb.lib", "maxminddb", PHP_MAXMINDDB)) { EXTENSION("maxminddb", "maxminddb.c"); } else { diff --git a/ext/libmaxminddb b/ext/libmaxminddb new file mode 160000 index 00000000..09a0540f --- /dev/null +++ b/ext/libmaxminddb @@ -0,0 +1 @@ +Subproject commit 09a0540fea89a16e5c6a9e21e93ee9aece6639e3 diff --git a/ext/tests/003-open-basedir.phpt b/ext/tests/003-open-basedir.phpt index 26e97819..ddd56cf8 100644 --- a/ext/tests/003-open-basedir.phpt +++ b/ext/tests/003-open-basedir.phpt @@ -1,5 +1,16 @@ --TEST-- openbase_dir is followed +--SKIPIF-- + --INI-- open_basedir=/--dne-- --FILE--