bext-php 0.2.0

Embedded PHP runtime for bext — custom SAPI linking libphp via Rust FFI
Documentation
//! Build script for bext-php: compiles the C SAPI bridge and links libphp.
//!
//! Requires PHP installed with `--enable-embed --enable-zts`:
//!   apt install libphp-embed php-dev   (Debian/Ubuntu)
//!   dnf install php-embedded php-devel (Fedora/RHEL)
//!   brew install php                   (macOS — ZTS may need source build)
//!
//! Set BEXT_PHP_CONFIG to override the `php-config` binary path.

use std::env;
use std::process::Command;

fn main() {
    // Locate php-config
    let php_config = env::var("BEXT_PHP_CONFIG").unwrap_or_else(|_| "php-config".into());

    // Get PHP compile flags
    let includes = php_config_flag(&php_config, "--includes");
    let ldflags = php_config_flag(&php_config, "--ldflags");
    let _libs = php_config_flag(&php_config, "--libs");
    let prefix = php_config_flag(&php_config, "--prefix");
    let extension_dir = php_config_flag(&php_config, "--extension-dir");

    // Detect ZTS (Thread Safety) support.
    //
    // We check php-config --configure-options AND whether the TSRM header
    // defines ZTS. NTS (non-thread-safe) PHP works for single-worker mode;
    // ZTS is required for multi-threaded worker pools.
    let configure_options = php_config_flag(&php_config, "--configure-options");
    let has_zts = configure_options.contains("enable-zts")
        || configure_options.contains("zts")
        || php_has_zts_define(&includes);

    // Register php_zts as a known cfg so Rust doesn't warn about it
    println!("cargo:rustc-check-cfg=cfg(php_zts)");

    if has_zts {
        println!("cargo:warning=PHP ZTS (thread safety) detected — multi-threaded pool enabled");
        println!("cargo:rustc-cfg=php_zts");
    } else {
        println!(
            "cargo:warning=PHP NTS detected. bext-php will run with a single worker. \
                  For multi-threaded PHP, rebuild PHP with --enable-zts."
        );
    }

    // Compile our C SAPI bridge
    let mut build = cc::Build::new();
    build.file("sapi/bext_php_sapi.c").warnings(true);

    // Parse include flags from php-config
    for flag in includes.split_whitespace() {
        if let Some(path) = flag.strip_prefix("-I") {
            build.include(path);
        }
    }

    // ZTS: define ZTS but NOT ZEND_ENABLE_STATIC_TSRMLS_CACHE.
    // lld (Rust's linker) doesn't do TLS symbol interposition, so we
    // can't have our own _tsrm_ls_cache alongside libphp.so's.
    // Instead, EG()/SG()/PG()/CG() use tsrm_get_ls_cache() calls.
    if has_zts {
        build.define("ZTS", None);
    }

    // pthreads
    build.flag("-pthread");

    build.compile("bext_php_sapi");

    // Link against libphp (the embed SAPI shared library).
    //
    // We link libphp *dynamically*. Its transitive dependencies (libxml2,
    // libsodium, libargon2, etc.) are resolved by the dynamic linker at
    // runtime — we do NOT need to link them explicitly. php-config --libs
    // reports them for static linking scenarios only.
    //
    // This means the build machine only needs:
    //   - libphp-embed (provides libphp.so)
    //   - php-dev (provides headers + php-config)
    // NOT the -dev packages for every PHP dependency.

    let lib_dir = format!("{}/lib", prefix);
    println!("cargo:rustc-link-search=native={}", lib_dir);

    // Also check extension dir parent and common system lib paths
    if let Some(parent) = std::path::Path::new(&extension_dir).parent() {
        println!("cargo:rustc-link-search=native={}", parent.display());
    }
    // libphp.so is often in /usr/lib directly
    println!("cargo:rustc-link-search=native=/usr/lib");

    // Parse ldflags for any -L paths (but skip -l flags — we handle linking below)
    for flag in ldflags.split_whitespace() {
        if let Some(path) = flag.strip_prefix("-L") {
            println!("cargo:rustc-link-search=native={}", path);
        }
    }

    // Link libphp.
    //
    // Strategy: prefer static linking (libphp.a) for portability. Fall back
    // to dynamic (libphp.so) if no static lib is available. When dynamic,
    // embed an RPATH so the binary finds libphp.so at runtime without
    // LD_LIBRARY_PATH.
    let static_lib = std::path::Path::new(&lib_dir).join("libphp.a");
    if static_lib.exists() {
        println!("cargo:warning=Linking libphp statically for portability");
        println!("cargo:rustc-link-lib=static=php");

        // Static PHP needs its transitive deps linked explicitly
        for flag in _libs.split_whitespace() {
            if let Some(lib) = flag.strip_prefix("-l") {
                println!("cargo:rustc-link-lib=dylib={}", lib);
            }
        }
    } else {
        println!("cargo:rustc-link-lib=dylib=php");

        // Embed RPATH so the final binary finds libphp.so at runtime.
        // This makes the binary portable — no LD_LIBRARY_PATH needed.
        // We write the lib_dir to a DEP_ env var that bext-server's build.rs
        // can read. We also set it directly (works for cdylib outputs).
        println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir);
        println!("cargo:php_lib_dir={}", lib_dir);
    }

    // Only link the truly essential system libs that our C code directly uses.
    // PHP's own transitive deps (xml2, sodium, argon2, etc.) are resolved
    // by the dynamic linker via libphp.so's DT_NEEDED entries.
    println!("cargo:rustc-link-lib=dylib=pthread");

    #[cfg(target_os = "linux")]
    {
        println!("cargo:rustc-link-lib=dylib=dl");
        println!("cargo:rustc-link-lib=dylib=resolv");
    }

    // Re-run if C source changes
    println!("cargo:rerun-if-changed=sapi/bext_php_sapi.c");
    println!("cargo:rerun-if-changed=sapi/bext_php_sapi.h");
    println!("cargo:rerun-if-env-changed=BEXT_PHP_CONFIG");
}

/// Check whether the PHP headers define ZTS by scanning for it in TSRM.h or main/php_config.h.
fn php_has_zts_define(includes: &str) -> bool {
    for flag in includes.split_whitespace() {
        if let Some(path) = flag.strip_prefix("-I") {
            // Check main/php_config.h for #define ZTS 1
            let config_h = std::path::Path::new(path).join("main/php_config.h");
            if let Ok(content) = std::fs::read_to_string(&config_h) {
                if content.contains("#define ZTS 1") || content.contains("#define ZTS") {
                    return true;
                }
            }
        }
    }
    false
}

/// Run php-config with the given flag and return trimmed stdout.
///
/// Security: validates that `php_config` is a real file path (not a shell
/// command string) before executing. This prevents command injection via
/// the `BEXT_PHP_CONFIG` environment variable in a compromised CI.
fn php_config_flag(php_config: &str, flag: &str) -> String {
    // Reject values containing shell metacharacters or whitespace that would
    // indicate a command string rather than a binary path (e.g. "sh -c '...'").
    let forbidden = [' ', '\t', ';', '|', '&', '`', '$', '(', ')', '{', '}', '<', '>', '\n', '\r', '\'', '"'];
    if php_config.contains(forbidden.as_slice()) {
        panic!(
            "BEXT_PHP_CONFIG contains shell metacharacters: {:?}. \
             It must be a path to the php-config binary (e.g. /usr/bin/php-config).",
            php_config
        );
    }

    // Verify the path exists and is a file (not a directory).
    let path = std::path::Path::new(php_config);
    if path.is_absolute() && !path.is_file() {
        panic!(
            "BEXT_PHP_CONFIG points to {:?} which is not a file. \
             Set it to the path of the php-config binary.",
            php_config
        );
    }

    match Command::new(php_config).arg(flag).output() {
        Ok(output) if output.status.success() => {
            String::from_utf8_lossy(&output.stdout).trim().to_string()
        }
        Ok(output) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            panic!(
                "php-config {} failed (exit {}): {}",
                flag, output.status, stderr
            );
        }
        Err(e) => {
            panic!(
                "Failed to run `{} {}`: {}. \
                 Is PHP (embed SAPI) installed? \
                 Set BEXT_PHP_CONFIG to override the php-config path.",
                php_config, flag, e
            );
        }
    }
}