エラーハンドリング戦略 - ha1t/php-gm-server GitHub Wiki

エラーハンドリング戦略

php-gm-server は Gemini プロトコルに従って、明確に分類されたエラーレスポンスを返します。本ページでは、エラー応答の分類、ステータスコードの使い分け、接続エラーと読み取りエラーの処理、バッファオーバーフロー時の処理、およびエラー時のコネクション終了処理について説明します。

エラーレスポンスの分類

php-gm-server は Gemini プロトコルで定義された 3 種類のレスポンスを返します。成功時のステータスコード 20、および 2 種類のエラーステータスコード 51(見つからない)と 59(不正リクエスト)です。各レスポンスは Gemini プロトコル形式(STATUS SPACE META\r\n BODY)で構築されます。

ステータスコード 20(成功)

ステータスコード 20 は、リクエストが正常に処理され、ファイルが発見・読み込みできた場合に返されます。レスポンス形式は 20 MIME_TYPE\r\nBODY です。META フィールドには MIME タイプが格納されます。

public static function success(string $mime, string $body): string
{
    return "20 {$mime}\r\n{$body}";
}

ソース: src/ResponseBuilder.php:9-12

レスポンス例:

20 text/gemini
# ウェルカム
このファイルです。

ステータスコード 51(見つからない)

ステータスコード 51 は、リクエストされたファイルまたはリソースが見つからない場合に返されます。レスポンス形式は 51 Not found\r\n で、ボディは送信されません。

public static function notFound(): string
{
    return "51 Not found\r\n";
}

ソース: src/ResponseBuilder.php:14-17

この状態は StaticFileHandler::handle()null を返す場合に発生します。これは以下の状況で起こります:

  • ファイルパスが realpath() で解決できない(ファイルが存在しない)
  • ファイルパスがドキュメントルートの外にある(パストラバーサル試行)
  • ファイルが実際のファイルではない(ディレクトリで index.gmi がない場合など)
  • ファイルの読み込みに失敗した(ファイル読み取り権限がない、など)

ステータスコード 59(不正リクエスト)

ステータスコード 59 は、不正なリクエストが送信された場合に返されます。レスポンス形式は 59 REASON\r\n で、REASON フィールドに具体的な理由が格納されます。

public static function badRequest(string $reason = 'Bad request'): string
{
    return "59 {$reason}\r\n";
}

ソース: src/ResponseBuilder.php:19-22

ステータスコード 59 が返される場合:

  • Invalid request: 送信されたリクエストが Gemini 形式として解析不可能(parse_url() で失敗、スキームが gemini 以外など)
  • Request too long: リクエスト行が 1024 バイトを超えている

エラーレスポンスフロー

GeminiServer のリクエスト処理フローにおいて、エラー検出は複数のステージで行われます。

sequenceDiagram
    participant client as Client
    participant server as GeminiServer
    participant parser as RequestParser
    participant handler as StaticFileHandler
    participant builder as ResponseBuilder

    client->>server: TLS接続

    alt バッファ内に\r\nが未検出
        client->>server: データ送信
        server->>server: バッファに追加
        alt バッファが1024バイト超過
            server->>builder: badRequest('Request too long')
            builder-->>server: 59レスポンス
            server-->>client: 59レスポンス
            server->>server: conn.end()
        else バッファが1024バイト以内
            server->>server: 待機(\r\n受信待ち)
        end
    else バッファ内に\r\nを検出
        server->>parser: parse(buffer)
        
        alt パース失敗(URL無効、スキーム非gemini、ホスト空など)
            parser-->>server: null
            server->>builder: badRequest('Invalid request')
            builder-->>server: 59レスポンス
            server-->>client: 59レスポンス
            server->>server: conn.end()
        else パース成功
            parser-->>server: {host, path}
            server->>handler: handle(path)
            
            alt ファイル見つからない or 読み込み失敗
                handler-->>server: null
                server->>builder: notFound()
                builder-->>server: 51レスポンス
                server-->>client: 51レスポンス
                server->>server: conn.end()
            else ファイル見つかった
                handler-->>server: {mime, body}
                server->>builder: success(mime, body)
                builder-->>server: 20レスポンス
                server-->>client: 20レスポンス
                server->>server: conn.end()
            end
        end
    end
Loading

ソース: src/GeminiServer.php:44-83

リクエスト検証とエラー処理

バッファオーバーフロー時の処理

GeminiServer は、接続ごとに $buffer 変数でデータを蓄積します。リクエスト行終了を示す \r\n が検出されるまで、データを受信し続けます。しかし、バッファサイズが 1024 バイトを超えた場合は、ステータスコード 59 を返して接続を終了します。

$buffer .= $data;

if (!str_contains($buffer, "\r\n")) {
    if (strlen($buffer) > 1024) {
        $conn->write(ResponseBuilder::badRequest('Request too long'));
        $conn->end();
    }
    return;
}

ソース: src/GeminiServer.php:51-60

1024 バイト制限は Gemini プロトコルで規定された最大リクエスト長です。RequestParser::MAX_REQUEST_LENGTH で定義されています。

private const MAX_REQUEST_LENGTH = 1024;

ソース: src/RequestParser.php:9

リクエスト形式検証エラー

RequestParser の parse() メソッドは、受け取ったリクエスト行に対して複数の検証を行います。いずれかの検証に失敗した場合は null を返します。

検証項目 詳細 失敗時の処理
行の長さ 最初の \r\n までの部分が 1024 バイト超 null 返却 → 59エラー
parse_url() 成功 parse_url() で URL をパース可能 null 返却 → 59エラー
スキーム検証 スキーム部が "gemini" のみ受け入れ null 返却 → 59エラー
ホスト検証 ホスト名が空でない null 返却 → 59エラー
パス処理 パスが空の場合は "/" に正規化 null でなく処理続行

ソース: src/RequestParser.php:14-47

parse() メソッドの実装:

public static function parse(string $raw): ?array
{
    $line = explode("\r\n", $raw, 2)[0];

    if ($line === '' || strlen($line) > self::MAX_REQUEST_LENGTH) {
        return null;
    }

    $parts = parse_url($line);

    if ($parts === false) {
        return null;
    }

    $scheme = $parts['scheme'] ?? '';
    if ($scheme !== 'gemini') {
        return null;
    }

    $host = $parts['host'] ?? '';
    if ($host === '') {
        return null;
    }

    $path = $parts['path'] ?? '/';
    if ($path === '') {
        $path = '/';
    }

    return [
        'host' => $host,
        'path' => $path,
    ];
}

ソース: src/RequestParser.php:14-47

ファイル解決エラーと見つからない処理

StaticFileHandler の handle() メソッドは、リクエストされたパスをドキュメントルート内で解決します。ファイルが見つからない、または読み込めない場合は null を返し、GeminiServer はステータスコード 51 を返します。

ファイル解決プロセス

flowchart TD
    A["handle(path) 開始"] --> B["filePath = docRoot + path"]
    B --> C{is_dir?}
    C -->|Yes| D["filePath = filePath + '/index.gmi'"]
    C -->|No| E["そのまま継続"]
    D --> E
    E --> F["realPath = realpath(filePath)"]
    F --> G{realPath成功?}
    G -->|False| H["null返却 → 51Not Found"]
    G -->|True| I{docRoot内か確認}
    I -->|False| J["null返却 → 51Not Found<br/>(パストラバーサル防止)"]
    I -->|True| K{ファイル存在?}
    K -->|False| L["null返却 → 51Not Found"]
    K -->|True| M["file_get_contents()"]
    M --> N{読み込み成功?}
    N -->|False| O["null返却 → 51Not Found"]
    N -->|True| P["MIME取得、{mime, body}返却"]
Loading

ソース: src/StaticFileHandler.php:35-68

パストラバーサル防止

StaticFileHandler は realpath() を使用してファイルパスを正規化し、その後、パスがドキュメントルート内に存在することを検証します。

$realPath = realpath($filePath);
if ($realPath === false) {
    return null;
}

if ($realPath !== $this->docRoot && !str_starts_with($realPath, $this->docRoot . DIRECTORY_SEPARATOR)) {
    return null;
}

ソース: src/StaticFileHandler.php:43-50

この検証により、/path/../../etc/passwd のようなパストラバーサル攻撃は防止されます。realpath() で正規化されたパスがドキュメントルートの外を指している場合、ファイルハンドラーは null を返し、サーバーはステータスコード 51 を返します。

ファイル読み取りエラー

ファイルが実在し、ドキュメントルート内にあっても、読み取り権限がない、またはファイルシステムエラーで読み込めない場合があります。その場合、file_get_contents()false を返し、ハンドラーは null を返します。

$body = file_get_contents($realPath);
if ($body === false) {
    return null;
}

ソース: src/StaticFileHandler.php:56-59

接続エラーの処理

GeminiServer は、接続とサーバーレベルで 2 種類の error イベントハンドラーを設定しています。

接続エラーハンドラー

個別の接続で発生したエラー(例:SSL/TLS ハンドシェイク失敗、既に閉じられた接続への書き込みなど)は、接続レベルの error イベントで捕捉されます。

$conn->on('error', function (\Throwable $e) {
    echo "Connection error: {$e->getMessage()}\n";
});

ソース: src/GeminiServer.php:45-47

接続エラーはログに出力されますが、エラーレスポンスは送信されません。これは、エラー発生時点で接続が既に破損しているか、クライアント側の問題によるものが多いためです。

サーバーレベルエラーハンドラー

サーバー全体で発生したエラー(例:ポートバインド失敗など)は、サーバーレベルの error イベントで捕捉されます。

$server->on('error', function (\Throwable $e) {
    echo "Error: {$e->getMessage()}\n";
});

ソース: src/GeminiServer.php:85-87

エラー時のコネクション終了処理

GeminiServer では、ステータスコード 59(不正リクエスト)またはステータスコード 51(見つからない)のいずれかを返した直後に、$conn->end() を呼び出して接続を終了します。

if (!str_contains($buffer, "\r\n")) {
    if (strlen($buffer) > 1024) {
        $conn->write(ResponseBuilder::badRequest('Request too long'));
        $conn->end();  // ← エラー時の接続終了
    }
    return;
}

$request = RequestParser::parse($buffer);

if ($request === null) {
    $conn->write(ResponseBuilder::badRequest('Invalid request'));
    $conn->end();  // ← エラー時の接続終了
    return;
}

$result = $this->fileHandler->handle($request['path']);

if ($result === null) {
    $conn->write(ResponseBuilder::notFound());
    $conn->end();  // ← エラー時の接続終了
    return;
}

$conn->write(ResponseBuilder::success($result['mime'], $result['body']));
$conn->end();  // ← 成功時の接続終了

ソース: src/GeminiServer.php:51-82

接続終了の意味

Gemini プロトコルでは、1 つのリクエスト/レスポンスペアの後、サーバーはコネクションを終了します(キープアライブなし)。エラーレスポンスを返す場合も、成功レスポンスを返す場合も、常に接続を閉じます。

  • エラー時: $conn->end() を呼び出して接続を閉じ、それ以上のリクエスト受信を防止
  • 成功時: レスポンス送信後に $conn->end() を呼び出して接続を閉じ、リクエスト完了

エラーレスポンスの例

例 1: リクエストが 1024 バイト超過

クライアント:

gemini://example.com/[1000文字のパス] + \r\n

サーバーのバッファサイズが 1024 バイト超過を検出:

59 Request too long\r\n
[接続終了]

ソース: src/GeminiServer.php:55-57

例 2: スキームが HTTP で送信

クライアント:

http://example.com/hello.gmi\r\n

RequestParser がスキーム検証に失敗:

59 Invalid request\r\n
[接続終了]

ソース: src/GeminiServer.php:64-67, src/RequestParser.php:28-31

例 3: リクエストされたファイルが存在しない

クライアント:

gemini://example.com/notfound.gmi\r\n

StaticFileHandler が realpath() で解決失敗、または ドキュメントルート外へのアクセス検出:

51 Not found\r\n
[接続終了]

ソース: src/GeminiServer.php:74-77, src/StaticFileHandler.php:43-50

例 4: パストラバーサル攻撃試行

クライアント:

gemini://example.com/../../../etc/passwd\r\n

StaticFileHandler が realpath() で正規化後、ドキュメントルート外のパスを検出:

51 Not found\r\n
[接続終了]

ソース: src/StaticFileHandler.php:48-50

テストケース

エラーハンドリングの各シナリオは、テストスイートによって検証されています。

RequestParser テスト

public function testRejectNonGeminiScheme(): void
{
    $result = RequestParser::parse("https://example.com/\r\n");
    $this->assertNull($result);
}

public function testRejectTooLongRequest(): void
{
    $longPath = str_repeat('a', 1024);
    $result = RequestParser::parse("gemini://example.com/{$longPath}\r\n");
    $this->assertNull($result);
}

public function testRejectEmptyRequest(): void
{
    $result = RequestParser::parse("");
    $this->assertNull($result);
}

ソース: tests/RequestParserTest.php:33-51

ResponseBuilder テスト

public function testNotFoundResponse(): void
{
    $response = ResponseBuilder::notFound();
    $this->assertSame("51 Not found\r\n", $response);
}

public function testBadRequestResponse(): void
{
    $response = ResponseBuilder::badRequest('Invalid request');
    $this->assertSame("59 Invalid request\r\n", $response);
}

ソース: tests/ResponseBuilderTest.php:18-28

関連ページ

⚠️ **GitHub.com Fallback** ⚠️