claviron-php-embed 0.2.0-alpha.20

Managed in-process PHP execution, application hosting, and native extensions for Claviron
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use claviron_php::PhpRunner;
use claviron_runtime::RunMode;

use super::*;

fn temp_root() -> std::path::PathBuf {
    let nanos =
        SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
    let root =
        std::env::temp_dir().join(format!("claviron-sapi-engine-{nanos}"));
    std::fs::create_dir_all(&root).unwrap();
    root
}

pub(super) fn runtime(root: std::path::PathBuf) -> Arc<PhpRuntime> {
    Arc::new(PhpRuntime {
        path: None,
        entry_script: None,
        entry_query: None,
        try_files: false,
        entry_extensions: Arc::from(Vec::<String>::new().into_boxed_slice()),
        validate_script: false,
        indexes: Arc::from(vec!["index.php".to_string()].into_boxed_slice()),
        dir: root,
        fpm: Arc::from(Vec::<String>::new().into_boxed_slice()),
        environment: Arc::new(HashMap::new()),
        env_file: Arc::from([]),
        execution_environment: Default::default(),
        php_runner: PhpRunner::Script,
        application: None,
        requests: Default::default(),
        responses: Default::default(),
        server_run_mode: RunMode::Standalone,
        server_worker_count: 1,
        server_hostname: "test-host".to_string(),
        server_software: "claviron-test".to_string(),
    })
}

fn sapi_request(root: &std::path::Path) -> PhpSapiRequest {
    PhpSapiRequest {
        script_name: "/index.php".to_string(),
        script_file: root.join("index.php"),
        document_root: root.to_path_buf(),
        document_uri: "/index.php".to_string(),
        request_uri: "/index.php".to_string(),
        query_string: String::new(),
        path_info: None,
    }
}

fn http_request() -> Request<RouteBody> {
    Request::builder()
        .extension(claviron_http::policy::HttpPolicy::unlimited())
        .body(claviron_http::empty_body())
        .unwrap()
}

#[test]
fn script_failure_preserves_error_status_but_normalizes_success() {
    let mut captured =
        CapturedResponse { status: StatusCode::OK, ..Default::default() };
    captured.mark_script_failure();
    assert_eq!(captured.status, StatusCode::INTERNAL_SERVER_ERROR);

    let mut captured =
        CapturedResponse { status: StatusCode::FOUND, ..Default::default() };
    captured.mark_script_failure();
    assert_eq!(captured.status, StatusCode::INTERNAL_SERVER_ERROR);

    let mut captured = CapturedResponse {
        status: StatusCode::NOT_FOUND,
        ..Default::default()
    };
    captured.mark_script_failure();
    assert_eq!(captured.status, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn run_drains_and_resumes_php_pool() {
    let root = temp_root();
    std::fs::write(
        root.join("index.php"),
        include_str!("fixtures/engine/response.php"),
    )
    .unwrap();

    let engine = PhpSapiEngine::new();
    engine
        .start_pool(
            SapiPoolSettings {
                max_requests: None,
                threads_min: 1,
                threads_max: 2,
                threads_max_idle: 1,
                thread_idle_timeout: std::time::Duration::from_secs(60),
                thread_max_life: None,
            },
            CancellationToken::new(),
        )
        .unwrap();
    let response = engine
        .execute(
            runtime(root.clone()),
            sapi_request(&root),
            http_request(),
            None,
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::ACCEPTED);
    assert_eq!(response.headers().get("x-claviron-test").unwrap(), "yes");
    let body = response.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(body, Bytes::from_static(b"hello sapi"));

    assert!(engine.drain(None).await);
    let stopped = engine.pool_snapshot().unwrap();
    assert_eq!(stopped.threads_current, 0);
    let rejected = engine
        .execute(
            runtime(root.clone()),
            sapi_request(&root),
            http_request(),
            None,
        )
        .await
        .unwrap_err();
    assert!(matches!(
        rejected,
        SapiGatewayError::ServiceUnavailable(ref message)
            if message == "php request pool is not accepting requests"
    ));
    let after_rejection = engine.pool_snapshot().unwrap();
    assert_eq!(after_rejection.threads_current, stopped.threads_current);
    assert_eq!(
        after_rejection.threads_created_total,
        stopped.threads_created_total
    );

    engine
        .start_pool(
            SapiPoolSettings {
                max_requests: None,
                threads_min: 1,
                threads_max: 2,
                threads_max_idle: 1,
                thread_idle_timeout: std::time::Duration::from_secs(60),
                thread_max_life: None,
            },
            CancellationToken::new(),
        )
        .unwrap();
    let restarted = engine
        .execute(
            runtime(root.clone()),
            sapi_request(&root),
            http_request(),
            None,
        )
        .await
        .unwrap();
    assert_eq!(restarted.status(), StatusCode::ACCEPTED);
    assert!(engine.drain(None).await);
}

#[test]
fn request_scheme_isolated_process() {
    const CHILD: &str = "CLAVIRON_SCHEME_TEST_CHILD";
    if std::env::var_os(CHILD).is_none() {
        let ini = tempfile::tempdir().unwrap();
        std::fs::write(
            ini.path().join("php.ini"),
            "max_execution_time=0\ndisplay_errors=0\nlog_errors=1\n",
        )
        .unwrap();
        let (_, module) = module_path!().split_once("::").unwrap();
        let output =
            std::process::Command::new(std::env::current_exe().unwrap())
                .args([
                    "--exact",
                    &format!("{module}::request_scheme_isolated_process"),
                    "--nocapture",
                ])
                .env(CHILD, "1")
                .env("PHPRC", ini.path())
                .env("PHP_INI_SCAN_DIR", "")
                .output()
                .unwrap();
        assert!(
            output.status.success(),
            "{}\n{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(
            String::from_utf8_lossy(&output.stdout)
                .contains("test result: ok. 1 passed;")
        );
        return;
    }
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            tokio::time::timeout(std::time::Duration::from_secs(20), async {
                use claviron_http::ext::{ClientAuthStatus, TlsInfo};
                let root = tempfile::tempdir().unwrap();
                std::fs::write(
                    root.path().join("index.php"),
                    include_str!("fixtures/engine/scheme.php"),
                )
                .unwrap();
                let engine = PhpSapiEngine::new();
                engine
                    .start_pool(
                        SapiPoolSettings {
                            max_requests: None,
                            threads_min: 1,
                            threads_max: 1,
                            threads_max_idle: 1,
                            thread_idle_timeout: std::time::Duration::from_secs(
                                60,
                            ),
                            thread_max_life: None,
                        },
                        CancellationToken::new(),
                    )
                    .unwrap();
                for tls in [Some(false), Some(true), Some(false), None] {
                    let mut req = Request::builder()
                        .uri("/index.php?q=%2F")
                        .extension(
                            claviron_http::policy::HttpPolicy::unlimited(),
                        )
                        .header("x-forwarded-proto", "spoofed")
                        .header("forwarded", "proto=spoofed")
                        .body(claviron_http::empty_body())
                        .unwrap();
                    if let Some(tls) = tls {
                        req.extensions_mut().insert(Arc::new(ConnCtx {
                            listener: "test".parse().unwrap(),
                            proto: None,
                            remote: "127.0.0.1:1234".parse().unwrap(),
                            local_ip: "127.0.0.1".parse().unwrap(),
                            local_port: 8080,
                            real_ip: None,
                            country_code: None,
                            tls: tls.then_some(TlsInfo {
                                protocol_version: None,
                                cipher_suite: None,
                                client_auth: ClientAuthStatus::NotProvided,
                                client_cert: None,
                            }),
                        }));
                    }
                    let mut script = sapi_request(root.path());
                    script.request_uri = req.uri().to_string();
                    let response = engine
                        .execute(
                            runtime(root.path().to_path_buf()),
                            script,
                            req,
                            None,
                        )
                        .await
                        .unwrap();
                    let body = response
                        .into_body()
                        .collect()
                        .await
                        .unwrap()
                        .to_bytes();
                    let expected = match tls {
                        Some(true) => "https|on|https|-|NONE|/index.php?q=%2F",
                        Some(false) => "http|off|http|-|-|/index.php?q=%2F",
                        None => "-|-|-|-|-|/index.php?q=%2F",
                    };
                    assert_eq!(body.as_ref(), expected.as_bytes());
                }
                assert!(engine.drain(None).await);
            })
            .await
            .unwrap();
        });
}