エラーハンドリング戦略 - 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 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 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 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
ソース: 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;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}返却"]
ソース: 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()を呼び出して接続を閉じ、リクエスト完了
クライアント:
gemini://example.com/[1000文字のパス] + \r\n
サーバーのバッファサイズが 1024 バイト超過を検出:
59 Request too long\r\n
[接続終了]
ソース: src/GeminiServer.php:55-57
クライアント:
http://example.com/hello.gmi\r\n
RequestParser がスキーム検証に失敗:
59 Invalid request\r\n
[接続終了]
ソース: src/GeminiServer.php:64-67, src/RequestParser.php:28-31
クライアント:
gemini://example.com/notfound.gmi\r\n
StaticFileHandler が realpath() で解決失敗、または ドキュメントルート外へのアクセス検出:
51 Not found\r\n
[接続終了]
ソース: src/GeminiServer.php:74-77, src/StaticFileHandler.php:43-50
クライアント:
gemini://example.com/../../../etc/passwd\r\n
StaticFileHandler が realpath() で正規化後、ドキュメントルート外のパスを検出:
51 Not found\r\n
[接続終了]
ソース: src/StaticFileHandler.php:48-50
エラーハンドリングの各シナリオは、テストスイートによって検証されています。
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
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
- Geminiプロトコル仕様実装 — プロトコル仕様とサポート状況
- GeminiServerコンポーネント — メインサーバーの実装詳細
- RequestParserコンポーネント — リクエスト解析ロジック
- ResponseBuilderコンポーネント — レスポンス構築ロジック
- StaticFileHandlerコンポーネント — ファイルハンドリングと検証
- セキュリティ設計 — パストラバーサル防止などのセキュリティ対策
- リクエスト処理フロー — リクエスト全体フロー
- レスポンス構築フロー — レスポンス構築フロー