StaticFileHandlerコンポーネント - ha1t/php-gm-server GitHub Wiki

StaticFileHandlerコンポーネント

StaticFileHandler クラスは、Gemini プロトコルサーバーにおいて、ドキュメントルートからのファイル配信を担当するコンポーネントです。リクエストされたパスに対応するファイルの解決、ディレクトリハンドリング、MIME タイプの自動判定、およびパストラバーサル攻撃の防止機構を実装しています。アーキテクチャ設計で示される5つのコアコンポーネントの一つとして、GeminiServerコンポーネントからの要求に応じてファイル配信を処理します。

コンポーネント概要

StaticFileHandler は GeminiServer\StaticFileHandler クラスとして実装され、次の責務を持ちます。

  • ドキュメントルート(設定可能なディレクトリパス)の管理
  • リクエストパスからの物理ファイル解決
  • ディレクトリアクセス時の自動的な index.gmi ファイル返却
  • ファイル拡張子に基づく自動 MIME タイプ判定
  • realpath() による正規化とパストラバーサル攻撃防止

設計パターン

StaticFileHandler は単一責任の原則に従い、ファイルシステムへのアクセスとファイル情報の取得に専念します。他のコンポーネント(RequestParserコンポーネントによるリクエスト解析、ResponseBuilderコンポーネントによるレスポンス構築)との統合は、GeminiServerコンポーネントで処理されます。

クラス構成と初期化

class StaticFileHandler
{
    private string $docRoot;
    
    private const MIME_MAP = [ ... ];
    
    public function __construct(string $docRoot)
    {
        $this->docRoot = realpath($docRoot) ?: $docRoot;
    }
    
    public function handle(string $path): ?array
    {
        // ファイル解決ロジック
    }
}

ドキュメントルートの設定

コンストラクタで渡されたドキュメントルートパスは、realpath() 関数で正規化されます。このプロセスにより、シンボリックリンクが解決され、相対パスが絶対パスに変換されます。

public function __construct(string $docRoot)
{
    $this->docRoot = realpath($docRoot) ?: $docRoot;
}

realpath() が失敗した場合(ディレクトリが存在しない場合)は、渡されたパスがそのまま保持されます。これにより、初期化時の厳密なエラーハンドリングを避け、実際のファイルアクセス時にエラーが検出される仕様になっています。

出典: src/StaticFileHandler.php:27-30

ファイル解決フロー

StaticFileHandler の handle() メソッドは、与えられたパスに対応するファイルを解決します。

flowchart TD
    A[リクエストパス受信<br/>例: /about.gmi] -->|docRoot結合| B[物理パス構築<br/>docRoot + path]
    B -->|is_dir チェック| C{ディレクトリ<br/>か?}
    C -->|Yes| D[index.gmi を追加<br/>path/index.gmi]
    C -->|No| E[パス確定]
    D --> E
    E -->|realpath実行| F[正規化パス取得]
    F -->|パス検証| G{パストラバーサル<br/>チェック}
    G -->|不正| H[null 返却<br/>アクセス拒否]
    G -->|正当| I{ファイル<br/>存在?}
    I -->|No| H
    I -->|Yes| J[ファイル読み込み]
    J -->|MIME判定| K[拡張子からMIME取得]
    K --> L[mime と body を返却<br/>配列形式]
Loading

ステップ1: 物理パス構築

$filePath = $this->docRoot . $path;

ドキュメントルートとリクエストパスを連結して、物理的なファイルパスを構築します。

ステップ2: ディレクトリハンドリング

if (is_dir($filePath)) {
    $filePath = rtrim($filePath, '/') . '/index.gmi';
}

パスがディレクトリの場合、末尾のスラッシュを除去した後、/index.gmi を追加します。これにより、/sub/ へのアクセスは自動的に /sub/index.gmi に解決されます。

:

  • リクエスト: /{docRoot}/index.gmi
  • リクエスト: /sub/{docRoot}/sub/index.gmi
  • リクエスト: /about.gmi{docRoot}/about.gmi(変化なし)

ステップ3: パストラバーサル防止による正規化

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

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

このステップはセキュリティ設計の中核です:

  1. realpath() による正規化: realpath() 関数はシンボリックリンクを解決し、相対パス参照(..)を展開して、物理的な絶対パスを取得します。存在しないパスの場合は false を返します。

  2. ドキュメントルート内チェック: 正規化されたパスが:

    • ドキュメントルート自体と等しい、または
    • ドキュメントルート直下のディレクトリ/ファイルであることを確認

パストラバーサル攻撃(例: /../../../etc/passwd)は、realpath() の段階で存在しないパスとして扱われ、またはルートチェックで拒否されます。

攻撃の例と防止:

リクエスト: /../../../etc/passwd
パス構築: {docRoot}/../../../etc/passwd
realpath結果: /etc/passwd
チェック: /etc/passwd が docRoot 下か? → 否 → null返却

ステップ4: ファイル存在確認と読み込み

if (!is_file($realPath)) {
    return null;
}

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

ファイルが実際に存在し、読み込み可能か確認します。ディレクトリやシンボリックリンク、アクセス権限がない場合は null が返却されます。

ステップ5: MIME タイプ判定と結果構築

$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';

return [
    'mime' => $mime,
    'body' => $body,
];

ファイル拡張子(小文字に正規化)から MIME_MAP を参照して MIME タイプを決定します。マッピングにない拡張子の場合は、デフォルト値 application/octet-stream を使用します。

出典: src/StaticFileHandler.php:35-68

MIME タイプマッピング

StaticFileHandler は、ファイル拡張子から MIME タイプへのマッピングを定数 MIME_MAP として保持します。

private const MIME_MAP = [
    'gmi' => 'text/gemini',
    'gemini' => 'text/gemini',
    'txt' => 'text/plain',
    'html' => 'text/html',
    'css' => 'text/css',
    'js' => 'text/javascript',
    'json' => 'application/json',
    'png' => 'image/png',
    'jpg' => 'image/jpeg',
    'jpeg' => 'image/jpeg',
    'gif' => 'image/gif',
    'svg' => 'image/svg+xml',
    'pdf' => 'application/pdf',
];

サポートされた MIME タイプ一覧

拡張子 MIME タイプ 用途
.gmi, .gemini text/gemini Gemini テキストドキュメント
.txt text/plain プレーンテキスト
.html text/html HTML ドキュメント
.css text/css CSS スタイルシート
.js text/javascript JavaScript コード
.json application/json JSON データ
.png image/png PNG 画像
.jpg, .jpeg image/jpeg JPEG 画像
.gif image/gif GIF 画像
.svg image/svg+xml SVG ベクター画像
.pdf application/pdf PDF ドキュメント
(その他) application/octet-stream デフォルト(バイナリデータ)

デフォルト MIME タイプ

マッピングに存在しない拡張子のファイルは、デフォルト値 application/octet-stream で返却されます。これは、ファイルのバイナリデータとしてのダウンロードを意味します。

出典: src/StaticFileHandler.php:11-25

統合フロー: GeminiServer との連携

StaticFileHandler は GeminiServerコンポーネントから呼び出されます。統合フローは以下の通りです:

sequenceDiagram
    participant Client
    participant GeminiServer
    participant RequestParser
    participant StaticFileHandler
    participant ResponseBuilder
    
    Client->>GeminiServer: TLS接続、リクエスト送信
    GeminiServer->>RequestParser: raw buffer を parse
    RequestParser-->>GeminiServer: {host, path} 返却
    GeminiServer->>StaticFileHandler: path を handle()
    alt ファイル見つかる
        StaticFileHandler-->>GeminiServer: {mime, body}
        GeminiServer->>ResponseBuilder: success(mime, body)
        ResponseBuilder-->>GeminiServer: "20 mime\r\nbody"
    else ファイル見つからない
        StaticFileHandler-->>GeminiServer: null
        GeminiServer->>ResponseBuilder: notFound()
        ResponseBuilder-->>GeminiServer: "51 Not found\r\n"
    end
    GeminiServer->>Client: レスポンス返却
Loading

初期化例 (bin/server.php):

$docRoot = getenv('GEMINI_DOC_ROOT') ?: __DIR__ . '/../content';
$fileHandler = new StaticFileHandler($docRoot);
$server = new GeminiServer($host, $port, $fileHandler, $certPath);

コマンドラインから実行時に GEMINI_DOC_ROOT 環境変数でドキュメントルートを指定できます。詳細は環境変数設定リファレンスを参照してください。

出典: bin/server.php:13, 21

セキュリティ考慮事項

パストラバーサル攻撃防止

StaticFileHandler の核となるセキュリティ機構は、realpath() とドキュメントルート検証の組み合わせです:

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

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

この設計により:

  1. シンボリックリンク解決: /content/link/etc/passwd へのシンボリックリンクでも、realpath()/etc/passwd として解決
  2. 相対パス展開: /../../../etc/passwd/etc/passwd に展開される
  3. ドキュメントルート検証: 最終パスがドキュメントルート配下であることを確認

この多層防御により、サーバーメモリ上のドキュメントルート外へのアクセスが完全に防止されます。

詳細はセキュリティ設計を参照してください。

存在しないパスの扱い

realpath() は存在しないパスに対して false を返します。これにより、ファイルアクセス前の段階で不正なパスが除外されます。

if (is_dir($filePath)) {
    $filePath = rtrim($filePath, '/') . '/index.gmi';
}

$realPath = realpath($filePath);  // ファイル存在チェック
if ($realPath === false) {
    return null;
}

例えば、/nonexistent.gmi をリクエストしても、realpath() は失敗し、null が返却されます。

出典: src/StaticFileHandler.php:43-46, tests/StaticFileHandlerTest.php:70-75

テスト仕様

StaticFileHandler の動作は StaticFileHandlerTest で検証されます。主要なテストケースは以下の通りです:

public function testServeExistingFile(): void
{
    $handler = new StaticFileHandler($this->docRoot);
    $result = $handler->handle('/about.gmi');
    $this->assertSame('text/gemini', $result['mime']);
    $this->assertSame('# About', $result['body']);
}

public function testServeIndexForDirectory(): void
{
    $handler = new StaticFileHandler($this->docRoot);
    $result = $handler->handle('/');
    $this->assertSame('text/gemini', $result['mime']);
    $this->assertSame('# Welcome', $result['body']);
}

public function testPathTraversalBlocked(): void
{
    $handler = new StaticFileHandler($this->docRoot);
    $result = $handler->handle('/../../../etc/passwd');
    $this->assertNull($result);
}

テストケース一覧

テスト名 検証内容
testServeExistingFile 通常のファイル配信と MIME タイプ判定
testServeIndexForDirectory ルートディレクトリアクセス時の index.gmi 返却
testServeSubDirectoryIndex サブディレクトリへのアクセスと index.gmi 自動解決
testNonGmiMimeType .png など .gmi 以外のファイルの MIME タイプ判定
testFileNotFound 存在しないファイルへのアクセス(null 返却)
testPathTraversalBlocked パストラバーサル攻撃の防止

出典: tests/StaticFileHandlerTest.php:40-82

戻り値仕様

handle() メソッドは 2 種類の戻り値を返します:

成功時

array{
    'mime' => string,  // MIME タイプ
    'body' => string   // ファイル内容
}

失敗時

null

失敗する条件:

  • ファイルが存在しない
  • ディレクトリ内に index.gmi がない
  • パストラバーサル検証に失敗
  • ファイル読み込みに失敗
  • ファイルアクセス権限がない

出典: src/StaticFileHandler.php:33-34, 44-59

データフロー図

graph TD
    A["リクエストパス<br/>例: /sub/doc.gmi"] -->|handle呼び出し| B["ドキュメントルート結合<br/>docRoot/sub/doc.gmi"]
    B -->|ディレクトリ判定| C{ディレクトリ<br/>存在?}
    C -->|Yes| D["index.gmi追加<br/>docRoot/sub/index.gmi"]
    C -->|No| E["そのまま進行"]
    D --> F["realpath正規化"]
    E --> F
    F -->|シンボリックリンク解決<br/>相対パス展開| G["正規化パス取得"]
    G -->|docRoot外チェック| H{正当なパス<br/>か?}
    H -->|No| I["null返却"]
    H -->|Yes| J{ファイル<br/>存在?}
    J -->|No| I
    J -->|Yes| K["ファイル読み込み<br/>file_get_contents"]
    K -->|成功| L["拡張子抽出"]
    K -->|失敗| I
    L -->|MIME_MAP参照| M["MIME タイプ決定"]
    M --> N["配列構築<br/>mime + body"]
    N -->|返却| O["GeminiServer へ"]
    I -->|返却| O
Loading

実装における重要な詳細

pathinfo() による拡張子抽出

$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$mime = self::MIME_MAP[$ext] ?? 'application/octet-stream';

pathinfo() の第2引数 PATHINFO_EXTENSION はファイルの拡張子のみを返します。複数のドット(例: file.tar.gz)がある場合、最後のドットより後ろ部分(gz)が返されます。

拡張子は strtolower() で小文字に正規化されるため、FILE.GMIFile.Gmifile.gmi はすべて同じ MIME タイプにマッピングされます。

null の多用

StaticFileHandler は、エラー条件をすべて null で表現します:

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

この設計により、呼び出し元(GeminiServer)は単純な if ($result === null) チェックで全てのエラー条件を統一的に処理できます。これは ファイル配信システム詳細での 51(Not Found)エラーレスポンスの返却につながります。

出典: src/StaticFileHandler.php:44-59, src/GeminiServer.php:72-78

環境統合

StaticFileHandler はサーバー起動時に、環境変数 GEMINI_DOC_ROOT で指定されたディレクトリから初期化されます:

$docRoot = getenv('GEMINI_DOC_ROOT') ?: __DIR__ . '/../content';
$fileHandler = new StaticFileHandler($docRoot);

デフォルトは {project_root}/content ディレクトリです。環境変数設定リファレンスで詳細が説明されています。

ディレクトリ構成についてはディレクトリ構成と規約を参照してください。

出典: bin/server.php:13


Related Pages

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