Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Docker/dev/ubuntu-22.04/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions lib/Web/DevServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, string> $headers lowercase header name => value
*
* @return array<string, string> 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]) {
Expand Down
19 changes: 19 additions & 0 deletions script/docker-ci-local.sh
Original file line number Diff line number Diff line change
@@ -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}"
23 changes: 21 additions & 2 deletions test/real/ServeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,27 @@ public function testRejectsPathTraversal(): void
$this->assertStringNotContainsString('hidden', $response);
}

public function testPopulatesHttpServerHeaders(): void
{
$docroot = $this->makeDocroot([
'headers.php' => <<<'PHP'
<?php
echo $_SERVER['HTTP_HOST'], '|', $_SERVER['HTTP_X_CUSTOM'];
PHP,
]);
$response = $this->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<string, string> $extraEnv
* @param list<string> $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}";
Expand Down Expand Up @@ -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);

Expand Down
13 changes: 13 additions & 0 deletions test/real/cases/web_http_headers.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php
echo $_SERVER['HTTP_HOST'], $_SERVER['HTTP_X_CUSTOM'], "\n";
--EXPECT--
example.test1
38 changes: 38 additions & 0 deletions test/unit/Web/DevServerHeadersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace PHPCompiler;

use PHPCompiler\Web\DevServer;
use PHPUnit\Framework\TestCase;

final class DevServerHeadersTest extends TestCase
{
public function testHeaderNameToServerKey(): void
{
$this->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);
}
}
Loading