背景
コードレビューで、以下のコードに問題があると指摘を受けました。
if (!file_exists($schemaPath)) {
throw new InvalidArgumentException(...);
}
$schema = json_decode(file_get_contents($schemaPath), true);
一見すると問題がないように見えますが、実はこのコードには TOCTOU(Time Of Check To Time Of Use) と呼ばれる問題が潜んでいます。
この記事では、なぜ問題なのか、どのような状況で発生するのか、そして改善方法について整理します。
何が問題なのか
上記のコードは次の 2 段階で処理を行っています。
- ファイルの存在確認を行う
- ファイルを読み込む
コードレビューで指摘された内容を一言で表現すると、
file_exists() と file_get_contents() の組み合わせは TOCTOU になる
ということでした。
つまり、「確認」と「使用」の間で状態が変化する可能性があります。
例えば、ファイルの存在確認が終わった直後に別プロセスがファイルを削除した場合、後続の読み込み処理は失敗します。
TOCTOU とは
TOCTOU(Time Of Check To Time Of Use)は、
「確認した時点の状態」と「実際に使用する時点の状態」が一致するとは限らない
という問題です。
今回の例では、以下のような流れになります。
[時刻1]
file_exists() → true
--- この瞬間に別プロセスがファイルを削除 ---
[時刻2]
file_get_contents() → 失敗
確認した時点ではファイルが存在していても、その結果は未来まで保証されません。
これが TOCTOU の本質です。
なぜ起きるのか
状態は自分のコードだけで管理されているとは限りません。
例えば次のような要因で、チェック後に状態が変化する可能性があります。
- マルチプロセス環境
- 並列処理
- 別サーバーからの更新
- ユーザー操作
- cron ジョブ
- Docker Volume の更新
重要なのは、
「チェック結果は未来の状態を保証しない」
という考え方です。
そのため、「チェックが通ったから安全」という前提で設計すると、予期しない失敗が発生する可能性があります。
⸻
よくある TOCTOU の例
- ファイル操作
if (is_writable($path)) {
file_put_contents($path, $data);
}
確認後に権限が変更される可能性があります。
- 認証・権限管理
if ($user->isAdmin()) {
dangerousAction();
}
権限確認後に管理者権限が剥奪される可能性があります。
- 在庫管理
if ($stock > 0) {
buy();
}
確認後に別ユーザーが購入し、在庫がなくなる可能性があります。
対策
基本的な考え方は、
「確認してから使う」のではなく、「使って失敗を処理する」
ことです。
改善例
$content = file_get_contents($schemaPath);
if ($content === false) {
throw new InvalidArgumentException(...);
}
この実装では、
- 事前の存在確認を行わない
- 直接読み込みを試みる
- 失敗した場合にエラー処理を行う
という流れになります。
これにより、確認と使用の間に発生する競合状態を減らすことができます。
エラー抑制演算子(@)について
TOCTOU の説明記事では次のようなコードを見かけることがあります。
$content = @file_get_contents($schemaPath);
if ($content === false) {
throw new InvalidArgumentException(...);
}
しかし、実務では @ 演算子の使用はあまり推奨されません。
理由としては、
- 本来出るべき警告が見えなくなる
- 問題の調査が難しくなる
- ログから原因を追跡しづらくなる
ためです。
一般的には、エラーを抑制するのではなく、
- 戻り値を適切に確認する
- 例外へ変換する
- ログへ記録する
といった方法が推奨されます。
重要なのは、
TOCTOU の対策として必要なのは「事前チェックをなくすこと」であり、「@ を付けること」ではない
という点です。
今後意識したいポイント
TOCTOU はファイル操作だけの問題ではありません。
「状態を確認してから利用する」というパターンがある場合は、常に発生する可能性があります。
状態に含まれるもの
1. ファイル
file_exists($path);
確認後に、
- 削除
- リネーム
- 権限変更
- 内容変更
などが行われる可能性があります。
2. データベース
例えば、
- 在庫数
- 口座残高
- 予約枠
- ログイン状態
などです。
3. メモリ上の共有データ
if ($flag === false) {
doSomething();
}
チェック後に別スレッドが値を書き換える可能性があります。
4. OS リソース
例えば、
- プロセスの存在確認
- ポートの使用状況
- ロック状態
なども対象です。
5. ネットワーク状態
if ($server->isAlive()) {
...
}
確認後に接続が切断される可能性があります。
6. キャッシュ
if ($cache->has($key)) {
$cache->get($key);
}
確認後に別プロセスがキャッシュを削除する可能性があります。
7. ユーザー権限
if ($user->canEdit()) {
edit();
}
確認後に管理者が権限を変更する可能性があります。
設計レベルでの考え方
TOCTOU を避けるためには、
「成功前提」ではなく「失敗前提」で設計する
ことが重要です。
例えば、
try {
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException('Failed to read file.');
}
} catch (Throwable $e) {
// エラー処理
}
のように、実際の操作結果を基準に処理を組み立てます。
まとめ
今回のレビューで学んだことは、
「確認したから安全」ではない
ということでした。
file_exists() と file_get_contents() のようなコードは一見安全そうに見えますが、確認と使用の間で状態が変化する可能性があります。
TOCTOU はファイル操作に限らず、データベース、権限管理、キャッシュ、ネットワークなど、あらゆる共有リソースで発生します。
今後は、
「チェックしてから使う」のではなく、「使って失敗を処理する」
という考え方を意識して実装していきたいと思います。