パス正規化は「同じ場所を同じ文字列で表す」ための技
ファイルパスって、同じ場所を指しているのに書き方がバラバラになりがちです。logs/app/../app/current.log と logs/app/current.log は同じ場所を指しますし、./data/./input と data/input も意味としては同じです。
この「同じ場所なのに文字列が違う」状態を放置すると、比較がややこしくなったり、ログが読みにくくなったり、
セキュリティ的に危ないパス(../../ で上に抜けるなど)を見落としたりします。
そこで出てくるのが「パス正規化」です。
パスを一度“整形”して、「余計な . や .. を取り除く」「区切りを OS に合わせる」といった処理をしておくことで、
「同じ場所は同じ文字列で表す」状態に近づけます。
基本:Path#normalize で「.」「..」を整理する
まずは動きを見るシンプルな例
Java 7 以降では、java.nio.file.Path の normalize() を使ってパスを正規化できます。
import java.nio.file.Path;
public class NormalizeBasic {
public static void main(String[] args) {
Path raw = Path.of("logs/app/../app/./current.log");
Path normalized = raw.normalize();
System.out.println("raw = " + raw);
System.out.println("normalized= " + normalized);
}
}
Java出力イメージはこんな感じです。
raw = logs/app/../app/./current.log
normalized= logs/app/current.log
normalize() は、パスの中に含まれる .(カレントディレクトリ)や ..(一つ上のディレクトリ)を解決して、
できるだけシンプルな形にしてくれます。
ここで重要なのは、「normalize は“文字列置換”ではなく、“パス構造として解釈したうえで整理している”」ということです。
単に "../" を消しているわけではなく、「一つ上に戻る」という意味を理解したうえで、前後の要素をまとめています。
実務で使える「パス正規化ユーティリティ」の最小形
まず Path に変換してから normalize する
文字列のままゴリゴリ書き換えるのではなく、いったん Path にしてから normalize() するのが基本です。
import java.nio.file.Path;
public final class PathUtils {
private PathUtils() {}
public static Path normalize(Path path) {
if (path == null) {
return null;
}
return path.normalize();
}
public static Path normalize(String first, String... more) {
return normalize(Path.of(first, more));
}
}
Java使う側はこう書けます。
Path p1 = PathUtils.normalize("logs", "app", "..", "app", "current.log");
System.out.println(p1); // logs/app/current.log
Path p2 = PathUtils.normalize("data/./input/../input/users.csv");
System.out.println(p2); // data/input/users.csv
Javaここで深掘りしたいポイントは、「“パスの構造”として扱うことで、OS 依存の区切り文字を意識しなくてよくなる」ことです。
Windows なら \、Linux なら / ですが、Path.of と normalize を使えば、そこを自分で気にする必要がほぼなくなります。
絶対パス化と組み合わせる:toAbsolutePath().normalize()
「どこからの相対パスか」をはっきりさせる
相対パスのままだと、「今のカレントディレクトリがどこか」によって意味が変わってしまいます。
実務では、「最終的には絶対パスにしておく」ほうが安全な場面が多いです。
import java.nio.file.Path;
public class NormalizeAbsolute {
public static void main(String[] args) {
Path raw = Path.of("../logs/app/../app/current.log");
Path abs = raw.toAbsolutePath().normalize();
System.out.println("raw = " + raw);
System.out.println("abs = " + abs);
}
}
JavatoAbsolutePath() で「今のカレントディレクトリから見た絶対パス」に変換し、
そのうえで normalize() することで、「実際にどこを指しているのか」がはっきりします。
ここでの重要ポイントは、「比較やログ出力に使うパスは“絶対+正規化済み”に寄せるとトラブルが減る」という感覚です。
相対パスのまま比較すると、「同じ場所なのに違う文字列」として扱われてしまうことがよくあります。
例題:ユーザー指定のパスを正規化してから使う
「../」で上に抜けないかをチェックする
例えば、「ユーザーが指定したファイル名を、アプリの base ディレクトリ配下で扱う」というケースを考えます。
このとき、../../etc/passwd のようなパスをそのまま使うと、意図しない場所にアクセスしてしまう危険があります。
そこで、「base からの相対パスとして解釈し、正規化した結果が base 配下に収まっているか」をチェックする、というパターンがよく使われます。
import java.nio.file.Path;
public final class SafePaths {
private SafePaths() {}
public static Path resolveUnderBase(Path baseDir, String userInput) {
Path base = baseDir.toAbsolutePath().normalize();
Path resolved = base.resolve(userInput).normalize();
if (!resolved.startsWith(base)) {
throw new IllegalArgumentException("許可されていないパスです: " + userInput);
}
return resolved;
}
}
Java使う側はこうです。
Path base = Path.of("work/uploads");
Path safe = SafePaths.resolveUnderBase(base, "../secret.txt"); // ここは例外
Javaここで深掘りしたいのは、「正規化したうえで startsWith でチェックすることで、“../ で上に抜ける”攻撃を防いでいる」点です。resolve しただけでは ../ が残ることもありますが、normalize することで「実際にどこを指しているか」が確定し、
それが base の外なら弾く、というシンプルなロジックにできます。
これは、ファイルアップロード機能や簡易ファイルブラウザなど、「ユーザー入力からパスを組み立てる」場面で非常に重要なテクニックです。
ログや設定で「パスの揺れ」を減らす
ログ出力前に normalize しておく
障害調査でログを読むとき、「同じファイルなのにログ上のパス表記がバラバラ」だと、追いかけるのが大変です。
そこで、「ログに出す前に normalize しておく」という小さな工夫が効いてきます。
import java.nio.file.Path;
public final class PathLogging {
private PathLogging() {}
public static String toLogString(Path path) {
if (path == null) {
return "(null)";
}
return path.toAbsolutePath().normalize().toString();
}
}
Java使う側はこうです。
Path p = Path.of("logs/app/../app/current.log");
System.out.println("log file = " + PathLogging.toLogString(p));
Javaこれで、ログ上では常に「絶対+正規化済み」のパスが出るようになり、
「このログとあのログは同じファイルの話か?」という迷いが減ります。
設定ファイルから読んだパスもまず正規化する
設定ファイル(properties や YAML)にパスを書くことも多いですが、
そこでも「読み込んだらまず Path にして normalize する」という習慣をつけておくと、
OS 差や書き方の揺れに強いコードになります。
String pathStr = props.getProperty("app.log.path"); // 例: logs/app/../app/current.log
Path logPath = PathUtils.normalize(pathStr);
Javaパス正規化の“限界”と注意点
normalize は「実在するかどうか」は見ていない
normalize() は、あくまで「文字列としてのパス構造」を整理するだけで、「そのパスが実際に存在するかどうか」はチェックしません。
存在確認は別途 Files.exists や Files.isRegularFile などで行う必要があります。
つまり、「正規化したから安全」というわけではなく、
「比較しやすくなった」「../ を解決できた」というレベルの話だと理解しておくことが大事です。
OS ごとのルールに完全には依存しない設計にする
Path と normalize を使えば、区切り文字などは OS に合わせてくれますが、
「大文字小文字を区別するか」「ドライブレターがあるか」などは OS によって違います。
パスの比較やキーとしての利用(Map のキーなど)をするときは、
「ファイルシステムの特性に依存しすぎない」ように注意する必要があります。
実務では、「あくまで“同じ JVM 上での比較”に使う」「ログ用に整える」といった用途に留めておくと扱いやすいです。
まとめ:パス正規化ユーティリティで身につけるべき感覚
パス正規化は、「同じ場所を指しているパスを、できるだけ同じ形にそろえる」ための技です。
押さえておきたい感覚はこうです。
パスは文字列のまま扱わず、まず Path.of で Path にしてから normalize() する。
比較やログ出力に使うときは、「絶対パス+正規化済み」に寄せると揺れが減る。
ユーザー入力からパスを組み立てるときは、「base.resolve → normalize → startsWith(base)」で“上に抜ける”のを防ぐ。
設定ファイルから読んだパスも、最初に正規化しておくことで OS 差や書き方の揺れに強くする。normalize は存在確認や権限チェックはしないので、「安全性の一部」であって「全部」ではないと理解して使う。
