PHP Tips | 文字列処理:URL・パス系 – パス正規化(../ 除去)

PHP PHP
スポンサーリンク

「パス正規化(../ 除去)」で何をしたいのかイメージする

まず、ゴールのイメージからはっきりさせます。

"/var/www/html/../shared/config.php"   → "/var/www/shared/config.php"
"/var/www//app/./logs/../tmp/"        → "/var/www/app/tmp"
"/a/b/c/../../d"                      → "/a/d"
"../relative/path/./to/../file.txt"   → "relative/path/file.txt"(相対パスとして扱う場合)

やりたいことはこうです。

...、連続する / をきれいに整理して、
“最短で意味が同じパス” に変換したい」

業務だと、こんな場面でよく出てきます。

  • 設定ファイルやコードの中で、相対パスを結合した結果を「きれいな絶対パス」にしたい。
  • ログに出すパスを、見やすく・比較しやすくしたい。
  • セキュリティ的に「../ で上の階層に抜けていないか」をチェックしたい。

ここを文字列置換だけでなんとかしようとすると、
/../ を消したらおかしくなるケースなどが出てきて、すぐに破綻します。
だからこそ、「正しいアルゴリズムで .. を解決する」小さなユーティリティを持っておく価値が大きいです。


まずは「../ がどう動くか」をちゃんと理解する

. と .. の意味を整理する

ファイルパスの世界では、... には特別な意味があります。

.   → 今のディレクトリ(カレントディレクトリ)
..  → 一つ上のディレクトリ

例えば、

/var/www/html/./logs   → /var/www/html/logs
/var/www/html/../shared → /var/www/shared

./ は「そのまま」と同じなので、消しても意味は変わりません。
../ は「一つ上に戻る」ので、その前のディレクトリを一つ消すイメージです。

文字列置換ではダメな理由

よくある「やってはいけない」例です。

$path = '/var/www/html/../shared/config.php';

// ダメな例
$bad = str_replace('/../', '/', $path);
// /var/www/html/shared/config.php  ← 一見よさそうに見えるが、たまたま
PHP

この方法は、ケースによっては破綻します。

$path = '/var/www/html/../../shared/config.php';

$bad = str_replace('/../', '/', $path);
// /var/www/shared/config.php  ← たまたま合っているように見えるが、
// パターンによっては壊れる
PHP

../ は「前のディレクトリを一つ消す」という“文脈依存”の動きなので、
単純な文字列置換では正しく扱えません。


正しい考え方:「パスを分解してスタックで処理する」

アルゴリズムのイメージ

../ を正しく処理するには、
パスを / で分割して「一つずつ積み上げていく」イメージで考えます。

  1. パスを / で分割して、配列にする
  2. 左から順に見ていく
  3. 通常の名前(var, www, html など)は「スタック」に積む
  4. . は無視する
  5. .. が来たら、スタックの最後を一つ取り除く(array_pop
  6. 最後にスタックを / でつなぎ直す

図で書くと、こんな感じです。

入力: /var/www/html/../shared/config.php

分割: ["", "var", "www", "html", "..", "shared", "config.php"]

スタックの動き:
  ""      → 無視
  "var"   → ["var"]
  "www"   → ["var", "www"]
  "html"  → ["var", "www", "html"]
  ".."    → ["var", "www"]        (一つ戻る)
  "shared"→ ["var", "www", "shared"]
  "config.php" → ["var", "www", "shared", "config.php"]

結果: /var/www/shared/config.php

この「スタックで処理する」考え方が、
パス正規化の一番大事なポイントです。


実際に PHP で「パス正規化」関数を書いてみる

絶対パス前提のシンプル版

まずは、「先頭が / の絶対パス」を前提にしたシンプル版から。

/**
 * 絶対パスを正規化する
 * - 連続する / を 1 個に
 * - . を削除
 * - .. を解決
 * 例: /var/www/html/../shared/config.php → /var/www/shared/config.php
 */
function normalizePath(string $path): string
{
    // 連続する / を 1 個に
    $path = preg_replace('#/+#', '/', $path);

    $segments = explode('/', $path);
    $stack    = [];

    foreach ($segments as $seg) {
        if ($seg === '' || $seg === '.') {
            // 何もしない(スキップ)
            continue;
        }

        if ($seg === '..') {
            // 一つ上の階層に戻る
            array_pop($stack);
            continue;
        }

        $stack[] = $seg;
    }

    // 絶対パスとして再構築
    $normalized = '/' . implode('/', $stack);

    // 末尾の / は基本的に削る(ルート / はそのまま)
    return $normalized === '/' ? $normalized : rtrim($normalized, '/');
}
PHP

動作例です。

echo normalizePath('/var/www/html/../shared/config.php');
// /var/www/shared/config.php

echo normalizePath('/var///www/./app/logs/../tmp/');
// /var/www/app/tmp

echo normalizePath('/a/b/c/../../d');
// /a/d

echo normalizePath('/a/././b/../');
// /a
PHP

ここでの重要ポイントは、

  • // をまとめる」「. を無視」「.. で一つ戻る」をきちんと順番にやっていること
  • 最後に「絶対パスとして / から始まる形」にしていること

です。


相対パスも扱いたい場合の拡張

先頭に / がない場合は「相対パス」として扱う

さっきの normalizePath は、
常に / から始まる絶対パスとして返していました。

でも、相対パスを扱いたい場面もあります。

"../relative/path/./to/../file.txt"
→ "relative/path/file.txt"

これを実現するには、「絶対パスか相対パスか」を見分けて、
返し方を変える必要があります。

/**
 * パスを正規化する(絶対・相対両対応)
 * - 先頭が / なら絶対パスとして扱う
 * - そうでなければ相対パスとして扱う
 */
function normalizePathFlexible(string $path): string
{
    $isAbsolute = str_starts_with($path, '/');

    // 連続する / を 1 個に
    $path = preg_replace('#/+#', '/', $path);

    $segments = explode('/', $path);
    $stack    = [];

    foreach ($segments as $seg) {
        if ($seg === '' || $seg === '.') {
            continue;
        }

        if ($seg === '..') {
            if (!empty($stack) && end($stack) !== '..') {
                array_pop($stack);
            } else {
                // 相対パスの場合は先頭側の .. を残すこともある
                if (!$isAbsolute) {
                    $stack[] = '..';
                }
            }
            continue;
        }

        $stack[] = $seg;
    }

    if ($isAbsolute) {
        $normalized = '/' . implode('/', $stack);
        return $normalized === '/' ? $normalized : rtrim($normalized, '/');
    }

    // 相対パス
    $normalized = implode('/', $stack);
    return $normalized === '' ? '.' : $normalized;
}
PHP

動作例です。

echo normalizePathFlexible('/var/www/html/../shared/config.php');
// /var/www/shared/config.php

echo normalizePathFlexible('../relative/path/./to/../file.txt');
// ../relative/path/file.txt  (先頭の .. を残す)

echo normalizePathFlexible('a/b/../../c');
// c

echo normalizePathFlexible('../../a/b');
// ../../a/b
PHP

相対パスの扱いは少し難しいですが、
「絶対パスと相対パスを区別して考える」ことが大事です。

初心者のうちは、まずは「絶対パス専用の normalizePath」から慣れていくのがおすすめです。


URL の「パス部分」を正規化したい場合

URL 全体ではなく「パス部分だけ」を normalizePath に通す

URL の場合も、「パス部分だけ」を正規化したいことがあります。

"https://example.com/app/../assets/css/./style.css"
→ "https://example.com/assets/css/style.css"

このときは、

  1. parse_url で URL を分解
  2. path だけを normalizePath に通す
  3. URL を再構築

という流れにします。

/**
 * URL のパス部分を正規化する(../ や . を解決)
 */
function normalizeUrlPath(string $url): string
{
    $parts = parse_url($url);

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

    $scheme   = $parts['scheme']   ?? null;
    $host     = $parts['host']     ?? null;
    $port     = $parts['port']     ?? null;
    $path     = $parts['path']     ?? '';
    $query    = $parts['query']    ?? null;
    $fragment = $parts['fragment'] ?? null;
    $user     = $parts['user']     ?? null;
    $pass     = $parts['pass']     ?? null;

    if ($path !== '') {
        $path = normalizePath($path);
    }

    $result = '';

    if ($scheme !== null) {
        $result .= $scheme . '://';
    }

    if ($user !== null) {
        $result .= $user;
        if ($pass !== null) {
            $result .= ':' . $pass;
        }
        $result .= '@';
    }

    if ($host !== null) {
        $result .= $host;
    }

    if ($port !== null) {
        $result .= ':' . $port;
    }

    $result .= $path;

    if ($query !== null && $query !== '') {
        $result .= '?' . $query;
    }

    if ($fragment !== null && $fragment !== '') {
        $result .= '#' . $fragment;
    }

    return $result;
}
PHP

動作例です。

echo normalizeUrlPath('https://example.com/app/../assets/css/./style.css');
// https://example.com/assets/css/style.css

echo normalizeUrlPath('https://example.com//a//b/../c/?x=1#top');
// https://example.com/a/c?x=1#top
PHP

クエリやフラグメントはそのまま残しつつ、
パスだけをきれいにできます。


実務での使いどころのイメージ

「ベースディレクトリ+相対パス」を安全に絶対パスにしたい

例えば、設定でこう書いてあるとします。

$baseDir = '/var/www/app';
$relative = '../shared/config/app.php';
PHP

これを「ちゃんとした絶対パス」にしたい。

$rawPath = $baseDir . '/' . $relative;
// /var/www/app/../shared/config/app.php

$fullPath = normalizePath($rawPath);
// /var/www/shared/config/app.php
PHP

このとき、normalizePath を通しておけば、
ログに出すときも、ファイル存在チェックをするときも、
「変な ../ が残っていない」状態で扱えます。

セキュリティ的なチェックの前処理として

ユーザー入力から相対パスを受け取るような場面では、
ディレクトリトラバーサル(../../etc/passwd など)を防ぐ必要があります。

$baseDir = '/var/www/app/public';
$userPath = $_GET['path'] ?? '';

$fullPath = normalizePath($baseDir . '/' . $userPath);

// ここで、「ベースディレクトリ配下かどうか」をチェック
if (!str_starts_with($fullPath, $baseDir . '/')) {
    // ベースディレクトリの外に出ている → 不正なパスとして拒否
}
PHP

normalizePath を通しておくことで、
../ を含んだパスも「実際にどこを指しているか」がはっきりするので、
チェックがしやすくなります。


まとめ:今日からの「パス正規化(../ 除去)」ユーティリティ

大事なところだけ、ぎゅっとまとめます。

.././ を含むパスを安全に扱うには、「文字列置換」ではなく「スタックで処理する」アルゴリズムが必須。
normalizePath の基本は、「/ で分割 → . は無視 → .. で一つ戻る → 残った要素を / でつなぐ」。
絶対パス専用の normalizePath から始めて、必要に応じて相対パス対応版や URL 版に広げていくと理解しやすい。
セキュリティチェックやログ出力の前処理として、「まず正規化してから考える」という習慣を持つと、パス周りのバグと事故が一気に減る。

核になるコードは、これです。

function normalizePath(string $path): string
{
    $path = preg_replace('#/+#', '/', $path);

    $segments = explode('/', $path);
    $stack    = [];

    foreach ($segments as $seg) {
        if ($seg === '' || $seg === '.') {
            continue;
        }
        if ($seg === '..') {
            array_pop($stack);
            continue;
        }
        $stack[] = $seg;
    }

    $normalized = '/' . implode('/', $stack);

    return $normalized === '/' ? $normalized : rtrim($normalized, '/');
}
PHP

もし、あなたのコードの中で「/../'' に置き換えている」「substr で無理やり削っている」ような箇所があれば、そこがこのユーティリティの差し替えポイントです。
その一箇所を normalizePath ベースに書き換えるだけでも、パス周りの安心感がかなり変わります。

タイトルとURLをコピーしました