「パス正規化(../ 除去)」で何をしたいのかイメージする
まず、ゴールのイメージからはっきりさせます。
"/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../ は「前のディレクトリを一つ消す」という“文脈依存”の動きなので、
単純な文字列置換では正しく扱えません。
正しい考え方:「パスを分解してスタックで処理する」
アルゴリズムのイメージ
../ を正しく処理するには、
パスを / で分割して「一つずつ積み上げていく」イメージで考えます。
- パスを
/で分割して、配列にする - 左から順に見ていく
- 通常の名前(
var,www,htmlなど)は「スタック」に積む .は無視する..が来たら、スタックの最後を一つ取り除く(array_pop)- 最後にスタックを
/でつなぎ直す
図で書くと、こんな感じです。
入力: /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"
このときは、
parse_urlで URL を分解pathだけをnormalizePathに通す- 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 . '/')) {
// ベースディレクトリの外に出ている → 不正なパスとして拒否
}
PHPnormalizePath を通しておくことで、../ を含んだパスも「実際にどこを指しているか」がはっきりするので、
チェックがしやすくなります。
まとめ:今日からの「パス正規化(../ 除去)」ユーティリティ
大事なところだけ、ぎゅっとまとめます。
../ や ./ を含むパスを安全に扱うには、「文字列置換」ではなく「スタックで処理する」アルゴリズムが必須。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 ベースに書き換えるだけでも、パス周りの安心感がかなり変わります。
