Skip to content

Instantly share code, notes, and snippets.

@masakielastic
Last active February 27, 2026 03:10
Show Gist options
  • Select an option

  • Save masakielastic/e01670198aa8e91e4bdcf5307cb4ba44 to your computer and use it in GitHub Desktop.

Select an option

Save masakielastic/e01670198aa8e91e4bdcf5307cb4ba44 to your computer and use it in GitHub Desktop.
tiny_http と ext-php-rs で embed PHP の HTTP サーバーを作成する

tiny_http と ext-php-rs で embed PHP の HTTP サーバー CLI を作成する

ソースコード

Cargo.toml

[package]
name = "php-embed-tinyhttp"
version = "0.1.0"
edition = "2021"

[dependencies]
ext-php-rs = { version = "0.15", features = ["embed"] }
tiny_http = "0.12"
os_pipe = "1.2"
libc = "0.2"

src/main.rs

use ext_php_rs::embed::Embed;
use os_pipe::pipe;
use std::{
    env,
    ffi::CString,
    io::Read,
    path::{Path, PathBuf},
};
use std::os::fd::AsRawFd;

fn main() {
    let (addr, docroot) = parse_args();
    let docroot = std::fs::canonicalize(&docroot).unwrap_or(docroot);

    // Embed::run は R: Default を要求するので、戻り値を () に固定する
    Embed::run(|| {
        if let Err(e) = run_server(&addr, &docroot) {
            eprintln!("server error: {e}");
        }
    });
}

fn parse_args() -> (String, PathBuf) {
    let mut addr = "127.0.0.1:8080".to_string();
    let mut docroot = PathBuf::from("./public");

    let mut it = env::args().skip(1);
    while let Some(a) = it.next() {
        match a.as_str() {
            "--addr" => {
                if let Some(v) = it.next() {
                    addr = v;
                }
            }
            "--docroot" => {
                if let Some(v) = it.next() {
                    docroot = PathBuf::from(v);
                }
            }
            _ => {}
        }
    }
    (addr, docroot)
}

fn run_server(addr: &str, docroot: &Path) -> Result<(), String> {
    let server = tiny_http::Server::http(addr)
        .map_err(|e| format!("tiny_http bind failed: {e}"))?;

    eprintln!("Listening on http://{addr}");
    eprintln!("Docroot: {}", docroot.display());

    for mut req in server.incoming_requests() {
        let method = req.method().as_str().to_string();
        let url = req.url().to_string();

        let mut _body = Vec::new();
        let _ = req.as_reader().read_to_end(&mut _body);

        let (path_part, query) = split_url(&url);
        let script_path = map_to_script(docroot, &path_part);

        let response = match script_path {
            Some(script) if script.is_file() => {
                set_env_for_request(&method, &url, query, &req);

                match capture_php_stdout(&script) {
                    Ok(out) => tiny_http::Response::from_string(out)
                        .with_status_code(200)
                        .with_header(
                            tiny_http::Header::from_bytes(
                                &b"Content-Type"[..],
                                &b"text/html; charset=utf-8"[..],
                            )
                            .unwrap(),
                        ),
                    Err(err_msg) => tiny_http::Response::from_string(err_msg)
                        .with_status_code(500)
                        .with_header(
                            tiny_http::Header::from_bytes(
                                &b"Content-Type"[..],
                                &b"text/plain; charset=utf-8"[..],
                            )
                            .unwrap(),
                        ),
                }
            }
            _ => tiny_http::Response::from_string("Not Found\n")
                .with_status_code(404)
                .with_header(
                    tiny_http::Header::from_bytes(
                        &b"Content-Type"[..],
                        &b"text/plain; charset=utf-8"[..],
                    )
                    .unwrap(),
                ),
        };

        let _ = req.respond(response);
    }

    Ok(())
}

fn split_url(url: &str) -> (String, &str) {
    if let Some((p, q)) = url.split_once('?') {
        (p.to_string(), q)
    } else {
        (url.to_string(), "")
    }
}

fn map_to_script(docroot: &Path, path_part: &str) -> Option<PathBuf> {
    let mut rel = path_part.trim_start_matches('/').to_string();
    if rel.is_empty() {
        rel = "index.php".to_string();
    }
    if rel.ends_with('/') {
        rel.push_str("index.php");
    }

    // 最小・安全寄り:php 以外は不許可
    if !rel.ends_with(".php") {
        return None;
    }
    if rel.contains("..") {
        return None;
    }

    Some(docroot.join(rel))
}

fn set_env_for_request(method: &str, uri: &str, query: &str, req: &tiny_http::Request) {
    env::set_var("REQUEST_METHOD", method);
    env::set_var("REQUEST_URI", uri);
    env::set_var("QUERY_STRING", query);
    env::set_var("SERVER_PROTOCOL", "HTTP/1.1");

    if let Some(h) = req
        .headers()
        .iter()
        .find(|h| h.field.equiv("Host"))
        .map(|h| h.value.as_str().to_string())
    {
        env::set_var("HTTP_HOST", &h);
        if let Some((name, port)) = h.split_once(':') {
            env::set_var("SERVER_NAME", name);
            env::set_var("SERVER_PORT", port);
        } else {
            env::set_var("SERVER_NAME", &h);
        }
    }
}

fn capture_php_stdout(script: &Path) -> Result<String, String> {
    let script_str = script
        .to_str()
        .ok_or_else(|| "script path is not valid utf-8".to_string())?;

    // NUL 混入を確実に検知(以前の NUL エラー対策)
    CString::new(script_str).map_err(|_| "script path contains NUL byte".to_string())?;

    let (mut reader, writer) = pipe().map_err(|e| format!("pipe failed: {e}"))?;

    let stdout_fd = std::io::stdout().as_raw_fd();
    let saved = unsafe { libc::dup(stdout_fd) };
    if saved < 0 {
        return Err("dup(stdout) failed".to_string());
    }

    unsafe {
        if libc::dup2(writer.as_raw_fd(), stdout_fd) < 0 {
            libc::close(saved);
            return Err("dup2(pipe->stdout) failed".to_string());
        }
    }
    drop(writer);

    // PHP 実行
    let r = Embed::run_script(script_str);

    // flush + stdout 復元
    unsafe {
        libc::fflush(std::ptr::null_mut());
        libc::dup2(saved, stdout_fd);
        libc::close(saved);
    }

    if let Err(e) = r {
        return Err(format!("PHP execution failed: {e:?}"));
    }

    let mut out = String::new();
    reader
        .read_to_string(&mut out)
        .map_err(|e| format!("read captured stdout failed: {e}"))?;

    Ok(out)
}

ビルドと実行

cargo run -- --addr 127.0.0.1:8080 --docroot ./public
<?php
header("Content-Type: text/plain; charset=utf-8");
echo "Hello from embedded PHP!\n";
echo "REQUEST_METHOD=" . ($_SERVER["REQUEST_METHOD"] ?? "") . "\n";
echo "REQUEST_URI=" . ($_SERVER["REQUEST_URI"] ?? "") . "\n";
echo "QUERY_STRING=" . ($_SERVER["QUERY_STRING"] ?? "") . "\n";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment