ファイルの条件付き削除は rename で場所を押さえてから

2026.09.04 09:00
2026.08.30 00:36
ファイルの条件付き削除は rename で場所を押さえてから

「ファイルの内容が想定どおりなら削除する」という処理を書きました。読み込んで、値を照合して、一致したら消す。最初はそれで十分に見えます。

ただ、同じファイルを別プロセスも更新する環境では、確認してから削除するまでの一瞬に内容が入れ替わります。古い内容を確認したのに、新しく置かれたファイルを消してしまう。いわゆるTOCTOU競合です。

const current = await readFile(path, 'utf8');
if (current.revision !== expected) return;
await unlink(path);

照合後に別プロセスが同じパスへ新しい内容を書けば、確認したのは古い内容なのに、新しい内容を削除してしまいます。

renameで先に占有する

const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
await rename(path, temp);

const current = JSON.parse(await readFile(temp, 'utf8'));
if (current.revision === expected) {
  await unlink(temp);
} else {
  await restore(temp, path);
}

同一ファイルシステム内の一意なパスへrenameした時点で、対象を原子的に確保できます。その後に元パスへ新しいファイルが作られても、こちらが操作するのはtempだけです。

復元にはlinkを使う

tempを元のパスへrenameすると、新しいファイルを上書きする可能性があります。link() が既存パスに対して EEXIST になる性質を使い、非上書きで戻します。

async function restore(temp, path) {
  try {
    await link(temp, path);
    await unlink(temp);
  } catch (e) {
    if (e.code === 'EEXIST') {
      await unlink(temp);
      return;
    }
    throw e;
  }
}

競合テストでは照合後のフックから元パスへ新候補を書き込み、その候補が残ることをassertします。

check → use             危険
occupy → check → use    安全

この処理で大事だったのは、「確認してから実行する」をそのまま安全だと思わないことでした。先にrenameで対象を押さえ、押さえたファイルだけを確認して使う。戻すときも、新しい候補を上書きしないところまで考えて、初めて一連の対策になります。

ファイル操作に限らず、確認と実行が別のタイミングになる処理には誰かが割り込めます。check → use を見かけたら、先に占有できないか考える。この見方を持っているだけでも、競合の見落としは減りました。

今回は以上です。