Java Tips | 基本ユーティリティ:パス正規化

Java Java
スポンサーリンク

パス正規化は「同じ場所を同じ文字列で表す」ための技

ファイルパスって、同じ場所を指しているのに書き方がバラバラになりがちです。
logs/app/../app/current.loglogs/app/current.log は同じ場所を指しますし、
./data/./inputdata/input も意味としては同じです。

この「同じ場所なのに文字列が違う」状態を放置すると、比較がややこしくなったり、ログが読みにくくなったり、
セキュリティ的に危ないパス(../../ で上に抜けるなど)を見落としたりします。

そこで出てくるのが「パス正規化」です。
パスを一度“整形”して、「余計な ... を取り除く」「区切りを OS に合わせる」といった処理をしておくことで、
「同じ場所は同じ文字列で表す」状態に近づけます。


基本:Path#normalize で「.」「..」を整理する

まずは動きを見るシンプルな例

Java 7 以降では、java.nio.file.Pathnormalize() を使ってパスを正規化できます。

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.ofnormalize を使えば、そこを自分で気にする必要がほぼなくなります。


絶対パス化と組み合わせる: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);
    }
}
Java

toAbsolutePath() で「今のカレントディレクトリから見た絶対パス」に変換し、
そのうえで 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.existsFiles.isRegularFile などで行う必要があります。

つまり、「正規化したから安全」というわけではなく、
「比較しやすくなった」「../ を解決できた」というレベルの話だと理解しておくことが大事です。

OS ごとのルールに完全には依存しない設計にする

Pathnormalize を使えば、区切り文字などは OS に合わせてくれますが、
「大文字小文字を区別するか」「ドライブレターがあるか」などは OS によって違います。

パスの比較やキーとしての利用(Map のキーなど)をするときは、
「ファイルシステムの特性に依存しすぎない」ように注意する必要があります。

実務では、「あくまで“同じ JVM 上での比較”に使う」「ログ用に整える」といった用途に留めておくと扱いやすいです。


まとめ:パス正規化ユーティリティで身につけるべき感覚

パス正規化は、「同じ場所を指しているパスを、できるだけ同じ形にそろえる」ための技です。

押さえておきたい感覚はこうです。

パスは文字列のまま扱わず、まず Path.of で Path にしてから normalize() する。
比較やログ出力に使うときは、「絶対パス+正規化済み」に寄せると揺れが減る。
ユーザー入力からパスを組み立てるときは、「base.resolve → normalize → startsWith(base)」で“上に抜ける”のを防ぐ。
設定ファイルから読んだパスも、最初に正規化しておくことで OS 差や書き方の揺れに強くする。
normalize は存在確認や権限チェックはしないので、「安全性の一部」であって「全部」ではないと理解して使う。

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