diff --git a/Docker/dev/ubuntu-22.04/Dockerfile b/Docker/dev/ubuntu-22.04/Dockerfile new file mode 100644 index 000000000..cdc0a129f --- /dev/null +++ b/Docker/dev/ubuntu-22.04/Dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV PHP_COMPILER_PHP=php +ENV COMPOSER_ALLOW_SUPERUSER=1 +ENV PHP_CS_FIXER_IGNORE_ENV=true + +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common gnupg \ + && add-apt-repository -y ppa:ondrej/php \ + && apt-get update && apt-get install -y --no-install-recommends \ + git curl unzip ca-certificates \ + php8.2-cli php8.2-mbstring php8.2-xml php8.2-ffi php8.2-posix php8.2-phar \ + php8.2-tokenizer php8.2-dom php8.2-xmlwriter \ + build-essential clang llvm-14-dev \ + && rm -rf /var/lib/apt/lists/* \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +WORKDIR /compiler + +CMD ["/bin/bash"] diff --git a/Makefile b/Makefile index dab728054..590446254 100755 --- a/Makefile +++ b/Makefile @@ -94,3 +94,21 @@ serve: .PHONY: test-18 test-18: rebuild-changed docker run -v $(shell pwd):/compiler ircmaxell/php-compiler:18.04-dev php vendor/bin/phpunit + +# Ubuntu 22.04 + PHP 8.2 dev image (issues #73, #202). Build once: make docker-build-22 +PHP_COMPILER_DEV_IMAGE ?= ghcr.io/PurHur/php-compiler:dev +LOCAL_DEV_IMAGE ?= php-compiler:22.04-dev + +.PHONY: docker-build-22 +docker-build-22: + docker build -t $(LOCAL_DEV_IMAGE) -t $(PHP_COMPILER_DEV_IMAGE) Docker/dev/ubuntu-22.04 + +# Run full local CI inside Docker (mount repo; harness hosts may need: tar | docker run -i …) +.PHONY: test-docker +test-docker: docker-build-22 + docker run --rm -v $(shell pwd):/compiler -w /compiler $(LOCAL_DEV_IMAGE) ./script/ci-local.sh + +# Quick PHPUnit in 22.04 dev image (after composer install on host or in container) +.PHONY: test-docker-quick +test-docker-quick: + docker run --rm -v $(shell pwd):/compiler -w /compiler $(LOCAL_DEV_IMAGE) php vendor/bin/phpunit --exclude-group llvm diff --git a/README.md b/README.md index 399c50ad3..cee3f8979 100755 --- a/README.md +++ b/README.md @@ -89,9 +89,35 @@ Non-`.php` files under the docroot (for example `style.css`) are served as stati ## Using docker -Docker is optional. The Makefile targets Ubuntu 16.04 and 18.04 images with PHP 7.4 for historical compatibility. For day-to-day development, prefer the host workflow above. Use `make test-18` for the 18.04 image once built. +Docker is optional on a normal dev machine. On **Runforge / harness hosts** (no system PHP or LLVM), use the PHP 8.2 dev image instead of apt-installing toolchains on the host. -To build, use make: +### Container development (PHP 8.2, Ubuntu 22.04) + +Build the dev image once: + +```console +make docker-build-22 +``` + +Run the full local CI suite inside the container (same as `./script/ci-local.sh` on the host): + +```console +make test-docker +# or: +docker run --rm -v "$(pwd):/compiler" -w /compiler php-compiler:22.04-dev ./script/ci-local.sh +``` + +If the bind-mount shows an empty `/compiler` directory (some harness setups), use: + +```console +./script/docker-ci-local.sh +``` + +Published tag (when available): `ghcr.io/PurHur/php-compiler:dev`. Override with `PHP_COMPILER_DEV_IMAGE`. + +Legacy Makefile targets use Ubuntu 16.04 / 18.04 images with PHP 7.4. For day-to-day development on a host with PHP 8.2, prefer the workflow above. Use `make test-18` for the 18.04 image once built. + +To build legacy images, use make: ```console me@local:~$ make build diff --git a/lib/Web/DevServer.php b/lib/Web/DevServer.php index c1b841c3b..3f70fc820 100644 --- a/lib/Web/DevServer.php +++ b/lib/Web/DevServer.php @@ -9,6 +9,12 @@ */ final class DevServer { + /** Maximum request headers mapped into $_SERVER (issue #77). */ + public const MAX_REQUEST_HEADERS = 128; + + /** Maximum length per header value passed to user scripts. */ + public const MAX_HEADER_VALUE_LEN = 8192; + public static function run(string $listen, string $docroot, callable $handlePhpRequest): void { if (!is_dir($docroot)) { @@ -115,6 +121,13 @@ public static function handleConnection($conn, string $docroot, callable $handle $cgiEnv['PATH_INFO'] = $pathInfo; } + self::clearHttpServerKeys(); + $httpServer = self::httpHeadersToServerVars($headers); + foreach ($httpServer as $key => $value) { + $_SERVER[$key] = $value; + } + $cgiEnv = array_merge($cgiEnv, $httpServer); + putenv('REQUEST_METHOD='.$method); putenv('QUERY_STRING='.$query); putenv('REQUEST_BODY='.$body); @@ -187,6 +200,49 @@ public static function readRequest($conn): ?array return [$method, $path, $query, $headers, $body]; } + /** + * Map an HTTP header name to a CGI $_SERVER key (e.g. host → HTTP_HOST). + */ + public static function headerNameToServerKey(string $name): string + { + return 'HTTP_'.strtoupper(str_replace('-', '_', $name)); + } + + /** + * @param array $headers lowercase header name => value + * + * @return array HTTP_* keys for $_SERVER / CGI env + */ + public static function httpHeadersToServerVars(array $headers): array + { + $serverVars = []; + $count = 0; + foreach ($headers as $name => $value) { + if ($count >= self::MAX_REQUEST_HEADERS) { + break; + } + if (str_contains($value, "\r") || str_contains($value, "\n")) { + continue; + } + if (strlen($value) > self::MAX_HEADER_VALUE_LEN) { + $value = substr($value, 0, self::MAX_HEADER_VALUE_LEN); + } + $serverVars[self::headerNameToServerKey($name)] = $value; + ++$count; + } + + return $serverVars; + } + + public static function clearHttpServerKeys(): void + { + foreach (array_keys($_SERVER) as $key) { + if (is_string($key) && str_starts_with($key, 'HTTP_')) { + unset($_SERVER[$key]); + } + } + } + public static function isSafeUrlPath(string $path): bool { if ('' === $path || '/' !== $path[0]) { diff --git a/script/docker-ci-local.sh b/script/docker-ci-local.sh new file mode 100755 index 000000000..dee15cc7f --- /dev/null +++ b/script/docker-ci-local.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Run script/ci-local.sh inside the PHP 8.2 dev container (issues #202, #73). +# Harness hosts where bind-mounts appear empty can pipe the repo via tar instead. +set -euo pipefail +cd "$(dirname "$0")/.." +IMAGE="${PHP_COMPILER_DEV_IMAGE:-php-compiler:22.04-dev}" + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Building dev image $IMAGE (make docker-build-22)..." + make docker-build-22 +fi + +if [[ -f vendor/bin/phpunit ]]; then + exec docker run --rm -v "$(pwd):/compiler" -w /compiler "$IMAGE" ./script/ci-local.sh "$@" +fi + +echo "Bind-mount has no vendor/; copying repo into container via tar..." +quoted=$(printf '%q ' "$@") +tar -cf - --exclude='.git' --exclude='.llvm' . | docker run --rm -i -w /compiler "$IMAGE" bash -c "tar -xf - && ./script/ci-local.sh ${quoted}" diff --git a/test/real/ServeTest.php b/test/real/ServeTest.php index 7a280bd8d..bc3722932 100644 --- a/test/real/ServeTest.php +++ b/test/real/ServeTest.php @@ -72,10 +72,27 @@ public function testRejectsPathTraversal(): void $this->assertStringNotContainsString('hidden', $response); } + public function testPopulatesHttpServerHeaders(): void + { + $docroot = $this->makeDocroot([ + 'headers.php' => <<<'PHP' +httpGet($docroot, '/headers.php', [], [ + 'Host: example.test', + 'X-Custom: 1', + ]); + $this->assertStringContainsString('HTTP/1.1 200', $response); + $this->assertStringContainsString('example.test|1', $response); + } + /** * @param array $extraEnv + * @param list $extraRequestHeaders */ - private function httpGet(string $docroot, string $path, array $extraEnv = []): string + private function httpGet(string $docroot, string $path, array $extraEnv = [], array $extraRequestHeaders = []): string { $port = $this->findFreePort(); $addr = "127.0.0.1:{$port}"; @@ -107,7 +124,9 @@ private function httpGet(string $docroot, string $path, array $extraEnv = []): s $conn = fsockopen('127.0.0.1', $port); $this->assertIsResource($conn); - fwrite($conn, "GET {$path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + $requestHeaders = array_merge(['Host: 127.0.0.1', 'Connection: close'], $extraRequestHeaders); + $headerBlock = implode("\r\n", $requestHeaders); + fwrite($conn, "GET {$path} HTTP/1.1\r\n{$headerBlock}\r\n\r\n"); $response = stream_get_contents($conn); fclose($conn); diff --git a/test/real/cases/web_http_headers.phpt b/test/real/cases/web_http_headers.phpt new file mode 100644 index 000000000..fac0d6e9d --- /dev/null +++ b/test/real/cases/web_http_headers.phpt @@ -0,0 +1,13 @@ +--TEST-- +Web: $_SERVER HTTP_* from CGI environment (issue #193) +--ENV-- +REQUEST_METHOD=GET +SCRIPT_NAME=/app/index.php +REQUEST_URI=/app?name=test +HTTP_HOST=example.test +HTTP_X_CUSTOM=1 +--FILE-- +assertSame('HTTP_HOST', DevServer::headerNameToServerKey('host')); + $this->assertSame('HTTP_X_CUSTOM', DevServer::headerNameToServerKey('x-custom')); + $this->assertSame('HTTP_USER_AGENT', DevServer::headerNameToServerKey('User-Agent')); + } + + public function testHttpHeadersToServerVars(): void + { + $vars = DevServer::httpHeadersToServerVars([ + 'host' => 'example.test', + 'x-custom' => '1', + ]); + $this->assertSame([ + 'HTTP_HOST' => 'example.test', + 'HTTP_X_CUSTOM' => '1', + ], $vars); + } + + public function testRejectsHeaderValueWithNewlines(): void + { + $vars = DevServer::httpHeadersToServerVars([ + 'x-inject' => "evil\r\nSet-Cookie: bad=1", + ]); + $this->assertSame([], $vars); + } +}