この記事について
この記事は、生成AIを活用した自動生成フローで作成しています。Python公式pathlib仕様を確認し、フォルダ内判定とシンボリックリンクの違いを小さな実験で整理しました。検証ステータス:📘 公式仕様確認済み・掲載コードの実行未確認
Pythonのpathlibでフォルダ内判定は安全?relative_toとresolveを比較する
「提出されたファイル名が、指定した作業フォルダの中にあるか」を判定したい。そんなとき、文字列の先頭が一致するかを調べるだけでは、似た名前の別フォルダを誤って許可してしまいます。Pythonのpathlibにはパスを部品として比較するrelative_to()とis_relative_to()があります。しかし、これらを使えば無条件に安全になるわけではありません。今回は、文字としてのパスと実際の参照先を区別し、なぜresolve()が必要になる場面があるのかを確かめます。
まず何が問題になるのか
例えば、架空の作業フォルダ/work/reportsの中だけを処理したいとします。/work/reports/2026.csvは対象ですが、/work/reports-old/2026.csvは別フォルダです。単純な文字列の前方一致なら、どちらも/work/reportsで始まるため、後者まで許してしまいます。見た目の文字列が似ていることと、フォルダの親子関係が成立していることは違います。
さらに、/work/reports/../secret.txtのように親フォルダへ戻る指定や、フォルダ内のリンクが外部を指している場合もあります。つまり「入力文字列の形が内部に見える」「パスの部品が内部にある」「実際にたどる参照先が内部にある」は、それぞれ別の問いなのです。ファイルの一覧表示と、外部から指定されたパスの読み書きでは、必要な厳密さも異なります。
Python 3.11以降を想定し、標準ライブラリだけを使います。管理者権限も外部ライブラリも不要です。実験では一時ディレクトリに架空ファイルを作り、終了時に自動削除します。重要な業務ファイルを入力に使う必要はありません。
安全な実験:名前の似たフォルダを比べる
最初は実際のファイルを開かず、パスの構造だけを確認します。下の例では、PurePosixPathを使うため、Windows上で実行してもPOSIX形式の架空パスを同じ条件で比較できます。結果は実行環境によるファイルの有無に左右されません。
from pathlib import PurePosixPath
base = PurePosixPath("/work/reports")
samples = [
PurePosixPath("/work/reports/2026.csv"),
PurePosixPath("/work/reports-old/2026.csv"),
PurePosixPath("/work/other/2026.csv"),
]
for path in samples:
print(path, str(path).startswith(str(base)),
path.is_relative_to(base))
仕様から予想される結果は、最初のパスだけが両方ともTrue、残りのうちreports-oldは文字列判定だけがTrue、otherは両方Falseです。ここで重要なのは、is_relative_to()がパスを部品単位で比較することです。文字列の前方一致と異なり、reportsとreports-oldを別の名前として扱います。
ただし、この実験が示すのは字句的な親子関係です。実際のファイルが存在するか、アクセス権があるか、リンクがどこを指すかは調べていません。判定の意味を「このフォルダに実在する安全なファイル」と言い換えないことが最初の注意点です。
relative_toとis_relative_toの違い
relative_to(base)は、対象パスを基準フォルダから見た相対パスに変換します。例えば/work/reports/2026.csvなら2026.csvを得ます。一方、基準の外側にあるパスはValueErrorになります。is_relative_to(base)は変換した値ではなく、関係が成立するかどうかを真偽値で返します。例外を使って分岐したいか、単純に条件判定したいかで使い分けます。
ここで「相対パス」という言葉には注意が必要です。relative_to()は../otherを作って外側へ向かう経路を計算する関数ではありません。親子関係が成立しない場合にはエラーになる関数です。別のフォルダまでの相対経路が欲しいなら、目的に応じてos.path.relpath()などを検討します。似た名前のAPIでも、保証している内容は同じではありません。
また、PurePathの判定は基本的に字句的です。ドキュメント上でも、relative_to()やis_relative_to()はファイルシステムにアクセスせず、..を特別な移動として解決しないと説明されています。この点は、実際の読み書き先を制限したい場面で決定的な違いになります。
1条件を変える:親フォルダへ戻る指定
次は入力に..を加えます。例えば/work/reports/../secret.txtという表記には、途中にreportsという部品が現れます。しかし、ファイルシステム上で通常のパス解決をすると、親に戻って/work/secret.txtを指すことになります。
字句的な判定では、このような経路を安全な参照先と見なしてはいけません。ここでPath.resolve()を使うと、絶対パス化と..の解消、シンボリックリンクの解決が行われます。つまり比較する前に、実際に参照するパスの形へそろえる考え方です。
図は判定を行う順番だけを表します。左側の入力をすぐに開くのではなく、基準側と候補側を同じ方法で解決し、解決後の親子関係を確かめてから処理を続けます。
flowchart TD
A["利用者から候補パスを受け取る"] --> B["基準と候補をresolveで解決"]
B --> C{"解決後に基準の内側か"}
C -->|はい| D["用途に応じて追加検証"]
C -->|いいえ| E["処理を拒否"]
D --> F["許可された操作へ進む"]
この図の「追加検証」は省略できるとは限りません。存在確認、通常ファイルかどうか、権限、リンクの変更可能性、読み取り専用か書き込みかといった条件は、用途によって別に確認する必要があります。パス判定だけでファイル操作全体の安全性を保証するわけではありません。
一時フォルダで実際の参照先を観察する
次のコードは一時フォルダにreportsとoutsideを作り、内部ファイルと外部ファイルの候補を比較します。どちらも架空の短い文字列だけを書き込みます。ここではresolve(strict=True)を使い、存在しない候補を黙って通さないようにしています。
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
root = Path(tmp)
allowed = root / "reports"
outside = root / "outside"
allowed.mkdir()
outside.mkdir()
(allowed / "ok.txt").write_text("sample", encoding="utf-8")
(outside / "no.txt").write_text("sample", encoding="utf-8")
base = allowed.resolve(strict=True)
candidates = [
allowed / "ok.txt",
allowed / ".." / "outside" / "no.txt",
outside / "no.txt",
]
for candidate in candidates:
target = candidate.resolve(strict=True)
print(candidate.name, target.is_relative_to(base))
仕様上の期待結果は、ok.txtがTrue、no.txtの2例がFalseです。途中にreportsという名前が含まれていても、..を解決した後の位置が外側なら拒否されます。出力には一時フォルダの実際の名前も含まれるため、パス文字列全体は環境ごとに変わります。この例の掲載コードは制作環境で実行していないため、ここで示した真偽値は公式仕様に基づく期待値です。
TemporaryDirectoryのブロックを抜けると、作成した一時ディレクトリは通常削除されます。ただし、ファイルが他のプログラムに使用されている場合や権限の問題などで後片付けに失敗する可能性はあります。試すときは業務フォルダを代用せず、サンプル専用の領域で実行してください。
シンボリックリンクを使うと何が変わるか
フォルダの内部に見えるshortcutが、外部フォルダへのシンボリックリンクだったらどうでしょうか。入力文字列がreports/shortcut/no.txtであっても、実際に開くファイルはoutside/no.txtかもしれません。resolve()はリンクをたどるため、リンクが存在して正しく解決できれば外部の参照先へ正規化されます。解決後のパスを基準と比較すれば、単純な文字列判定では見落とす違いを観察できます。
ただし、リンク作成の可否はOSや権限によって異なります。Windowsではシンボリックリンクの作成に権限や開発者モードなどの条件が関わる場合があります。この記事の基本実験ではリンク作成を必須にしていません。リンクを作れない環境で実験が失敗しても、relative_to()の仕様そのものが誤っているわけではありません。
もっと重要なのは、resolve()した直後に別のプロセスがリンクを差し替えられる場合です。判定時に安全だったパスが、実際に開く時点では別の参照先になる可能性があります。これはTOCTOU、つまり確認時点と使用時点のずれです。信頼できない利用者が同じディレクトリを変更できる環境では、解決後のパスを一度検査するだけでは十分な防御になりません。
失敗・境界条件を切り分ける
候補が存在しない場合、resolve(strict=True)は例外を発生させます。これは入力が悪いことを検出するうえで役立ちますが、存在しない新規ファイルを作る処理にはそのまま使えません。新規作成の場合は、親ディレクトリの解決と作成先の扱いを分けて設計し、競合やリンク差し替えに注意する必要があります。
アクセス権がない場合や、リンクが循環している場合にも、環境とPythonのバージョンに応じた例外が発生し得ます。実務ではOSErrorなどを捕捉して、処理を拒否し、必要最小限の情報を記録します。すべての例外を握りつぶしてTrueにするのは避けます。逆に、画面にファイルの完全パスを無条件に表示すると、利用者へ内部構造を漏らすおそれがあるため、ログの公開範囲も分けて考えます。
Windowsではドライブ文字やUNCパス、大小文字の扱いなどがPOSIXと異なります。架空のPOSIXパスをPurePosixPathで比較した結果を、そのままWindows上の実ファイルアクセスの保証と見なさないでください。実ファイルの判定では、実行OSに合ったPathを使い、基準と候補を同じ環境で解決するのが基本です。
業務でどう使い分けるか
単なる一覧表示の絞り込みや、利用者が入力したフォルダ名の構造チェックなら、is_relative_to()の字句的な判定が便利です。例えば、既に信頼できるコードが生成したパスの整理では、余分なファイルシステムアクセスを避けられます。しかし、外部から渡されたファイル名を実際に読み込む場合には、resolve()で解決した後に比較するなど、参照先を意識した検証が必要です。
アップロード先や削除対象を制限するような高リスク操作では、これだけを認可の最終条件にしてはいけません。変更可能なディレクトリに対する競合、シンボリックリンク、OS権限、ファイルの種類、既存ファイルの上書き防止まで検討してください。可能なら専用の保存領域を用意し、プロセス権限を必要最小限にします。さらに厳密な操作では、OSのディレクトリ相対APIなどを利用し、検証とオープンを安全に結び付ける設計が必要です。
今回の発見は「フォルダ内かどうか」が一つの質問に見えて、実は三つの意味を持つことです。文字列の一致、パス部品の親子関係、実際にたどる参照先を混同しなければ、pathlibの関数を選ぶ基準が明確になります。実務では、判定に使うパスが信頼できるか、誰が変更できるか、判定後に何をするかまでを一組として考えましょう。
公式資料
Python公式:pathlib — https://docs.python.org/3/library/pathlib.html
Python公式:tempfile — https://docs.python.org/3/library/tempfile.html
