mkdir recursive の EEXIST ・ ENOENT 調査結果

Windows の Bun で、カレントより上やカレント自身を指す相対パスに fs.mkdir(path, { recursive: true }) を使うと EEXIST ・ ENOENT になる。Node.js・Deno と比べた

📅 作成: 2026-10-12 / 更新: 2026-10-31

概要と環境

recursive: true を付けた fs.mkdir は、フォルダが既にあればエラーにせずに返る(Node.js の仕様)。Windows の Bun では、. ・ .. を畳んだパスが .. で始まり(カレントより上を指し)、フォルダが既にあるときに EEXIST になる。カレント自身を指すパス(. ・ ./ ・ inner/..)でも失敗する。Node.js と Deno では起きない。

項目値
OSWindows 11 Home(10.0.26300)x64
Bun1.4.3(c6da4a4d3)。bun upgrade で最新であることを確認
Node.jsv26.11.1(v26.10.0 でも同じ結果)
Deno2.9.7。deno upgrade で最新であることを確認

結果

カレントを sub にして、sub と同じ階層の exists(既にある)と new-dir(まだ無い)を指定した。

API ・ 渡したパスBun 1.4.3Node.js v26.11.1Deno 2.9.7
mkdirSync('../exists')❌ EEXIST✅✅
mkdirSync('..\\exists')❌ EEXIST✅✅
mkdirSync('./../exists') / '.\\..\\exists'(./ で始まる)❌ EEXIST✅✅
mkdirSync('inner/../../exists')(途中の .. で上に出る)❌ EEXIST✅✅
mkdirSync('../sub/inner') / '../sub/../exists'❌ EEXIST✅✅
mkdirSync('.')(カレント自身)❌ EEXIST✅✅
mkdirSync('./') / '.\\' / 'inner/..'(カレント自身)❌ ENOENT✅✅
mkdirSync(絶対パス)✅✅✅
mkdirSync('inner') / './inner' / 'inner/../inner'(カレントの中に留まる)✅✅✅
mkdirSync('../new-dir') 1 回目(まだ無い)✅ 作られる✅✅
mkdirSync('../new-dir') 2 回目(もうある)❌ EEXIST✅✅
fs.promises.mkdir('../exists')❌ EEXIST✅✅
fs.mkdir('../exists', cb)❌ EEXIST✅✅

Bun の出力(そのまま。絶対パスは伏せた):

NG   ../exists  EEXIST EEXIST: file already exists, mkdir '..\exists'
NG   ..\exists  EEXIST EEXIST: file already exists, mkdir '..\exists'
NG   ./../exists  EEXIST EEXIST: file already exists, mkdir '..\exists'
NG   .\..\exists  EEXIST EEXIST: file already exists, mkdir '..\exists'
NG   inner/../../exists  EEXIST EEXIST: file already exists, mkdir '..\exists'
NG   ../sub/inner  EEXIST EEXIST: file already exists, mkdir '..\sub\inner'
NG   ../sub/../exists  EEXIST EEXIST: file already exists, mkdir '..\exists'
OK   <absolute path>\exists
OK   inner
OK   ./inner
OK   inner/../inner
NG   .  EEXIST EEXIST: file already exists, mkdir '.'
NG   ./  ENOENT ENOENT: no such file or directory, mkdir
NG   .\  ENOENT ENOENT: no such file or directory, mkdir
NG   inner/..  ENOENT ENOENT: no such file or directory, mkdir
OK   ../new-dir
NG   ../new-dir  EEXIST EEXIST: file already exists, mkdir '..\new-dir'
NG   fs.promises.mkdir ../exists  EEXIST
NG   fs.mkdir ../exists  EEXIST

分かったこと

既存の issue と修正 PR

同じ症状の issue が既にあり、修正の PR も出ている。新しい issue は立てず、既存の issue にコメントを付けた(2026-10-12 に投稿したコメント)。

PR #42014 が挙げている原因

PR の説明によると、次の流れになる。

  1. recursive の fs.mkdir は CreateDirectoryW を呼び、EEXIST が返ると、そこにあるものがフォルダかを directory_exists_at_w で調べる
  2. その確認は NT の API を直接呼ぶ。名前を作る to_nt_path は区切りを変えるだけで、. ・ .. を解決しない。NT は .. を文字どおりの名前として扱うので、確認が失敗する
  3. 確認が失敗したので、フォルダではないと判断され、EEXIST が返る
  4. 再帰の処理は、先に . ・ .. を字面で畳む。畳んだ結果の先頭に残る .. は、元のフォルダのパスが無いと畳めないので、そのまま NT に渡る

この説明は、今回の結果と合う。inner/../inner は畳むと inner になって通り、inner/../../exists は畳むと ..\exists になって失敗する。

PR #42014 のソースを読んだ

先頭コミット cfff26e9a の差分を読んだ。直しているのは src/paths/classify.rs と src/sys/lib.rs の 2 つで、残りはテスト。

読んだ内容は、PR の説明と合う。

PR #42014 のビルドで確かめた

PR の CI(Buildkite #113149)が作った Windows x64 のビルド bun-windows-x64.zip を取得した。SHA-256 は一覧の値と一致し、版は 1.4.3-canary.1+cfff26e9a(PR の先頭コミット)だった。このビルドで、カレントより上を指す場合と . はすべて通った。カレント自身を指す ./ ・ .\ ・ inner/.. の 3 件は、このビルドでも ENOENT になる(再現の手順 の表)。原因は次の「ENOENT の原因」。

ほかの PR のビルドで確かめた

同じ手順で、#40536 と #42635 の Windows x64 のビルドも取得した。どちらも SHA-256 は一覧の値と一致した。

PRビルドカレントより上../ ・ .\ ・ inner/..
#45410Buildkite #127041、1.4.4-canary.1+4a119e097❌ EEXIST✅✅
#45410Buildkite #126187、1.4.4-canary.1+94fbd3674❌ EEXIST✅✅
#40536Buildkite #106239、1.4.1-canary.1+8c04559b9✅✅✅
#42635Buildkite #115200、1.4.3-canary.1+891eb8df5✅✅❌ ENOENT
#42014Buildkite #113149、1.4.3-canary.1+cfff26e9a✅✅❌ ENOENT

4 つの PR のうち、すべての場合を直すのは #40536 だけ。#40536 は 1.4.1 を元にしたビルドで、2026-08-26 から更新がない。#45410 は . と ENOENT の 3 件を直すが、カレントより上を指す 10 件は、このビルドでも EEXIST のまま(PR の説明は、これを #42014 が扱うとしている)。#42014 と #42635 は、その逆になる。

#45410 のビルドも同じ手順で調べた。PR の先頭のコミット 94fbd3674 で、取得したものは Buildkite が示す SHA-1 と一致した。取得した bun-windows-x64.zip の SHA-256 は 8a0b522fb15843a3d38ddd019c179894c961dcfdf79c083d2f02a5c0522b79ee。Buildkite のビルド全体は失敗の表示(184 ジョブ中 3 件)だが、Windows x64 のビルドのジョブは終わり、zip が出ていた。

その後、#45410 にコミットが 2 件(9d1de7022 ・ 4a119e097)増えた。差分では、os_path_kernel32 のコードは以前と同じで、変わったのはコメントの短縮と、試験を厳しくした点(toStrictEqual、code を持たないエラーはメッセージを残す)だけだった。新しい先頭のコミット 4a119e097 のビルド(Buildkite #127041)も同じ手順で調べた。取得した bun-windows-x64.zip の SHA-256 は 02ef8f1b6d7a9cf6d7f5340c36fb21917e7001118096a60cf92fb540cd1815f8 で、Buildkite が示す値と一致し、版は 1.4.4-canary.1+4a119e097 だった。結果は 94fbd3674 と同じで、. と ENOENT の 3 件は通り、カレントより上を指す 10 件は、まだ EEXIST を投げる。

#42635 は、2026-10-24 に作者が main へ rebase した(コミット e3d1ac77c)。差分で変わったのは、行の位置とドキュメントのコメントだけだった。作者は、Linux の試験は通り、Windows の実行時の挙動は再確認していないと書いている。新しい先頭のコミットは、Windows で試せなかった。その Buildkite のビルド(#131940)は blocked で、Windows x64 の zip が無い。上の表の行は、以前のコミット 891eb8df5 のビルドのままである。

ENOENT の原因(ソースで確かめた)

Bun 1.4.3(c6da4a4d3)の src/runtime/node/types.rs にある os_path_kernel32 が、mkdir に渡す前にパスを字面で正規化する(normalize_string_buf)。

直し方の候補: 正規化の結果が空なら . にする(今の . の特別扱いを、正規化の後ろへ移す)。PR #45410 は別の道を取り、空になった結果を getcwd で得たカレントの絶対パスに置き換える。PR の説明は、. にすると ./ が ENOENT から EEXIST に変わるだけだと書いている(NT の確認が . を解決できないため)。. にする案は、こちらでは試していない。

各 PR のソースと、直る・直らない理由

PR変えているところENOENT の 3 件
#45410os_path_kernel32 そのものを変える。正規化した相対パスが空で、元の入力が空でないとき、getcwd で得たカレントを使う。ちょうど . だけの特別扱いは外す。existsSync も同じ関数を通る✅ 空の文字列が kernel32 に渡らない。❌ カレントより上を指すパスには手を付けていない(PR は #42014 に任せている)
#40536node_fs.rs の recursive mkdir の入口で、相対パスをカレントと結合して(join_cwd_windows)から正規化する✅ 結合したあとなので、正規化しても空にならず、カレントの絶対パスになる
#42635既にあるかの確認を、NT の API から libuv の Win32 の stat(stat_w)に替える❌ 確認の前の、os_path_kernel32 の正規化は変えていない
#42014NT に渡す名前の . ・ .. を、フォルダのハンドルを基準に解決する(nt_object_name_at)❌ 同じく、os_path_kernel32 の正規化は変えていない
確かめていないこと: Linux ・ macOS で起きるか(PR は Linux では修正の前後とも通ると書いている)。

再現の手順

再現コード

research/repro.ts を空のフォルダで実行する。

bun repro.ts
node repro.ts
deno run -A repro.ts

テスト

tests/mkdir-recursive.test.ts は node:test で書いてあり、3 つのランタイムで同じものが流れる。tools\40_test\run-tests.cmd で、node ・ deno ・ bun の順に流して合否をまとめる。

ランタイム結果
Node.js v26.11.1✅ 18 件成功
Deno 2.9.7✅ 18 件成功
Bun 1.4.3❌ 4 件成功 ・ 14 件失敗(カレントより上を指す 10 件と、カレント自身を指す 4 件)
Bun 1.4.4(38afee6c8。2026-10-22 の公式リリース)❌ 4 件成功 ・ 14 件失敗(1.4.3 と同じ。カレントより上を指す 10 件と、カレント自身を指す 4 件)
Bun 1.4.4-canary.1+4a119e097(PR #45410 のビルド。2026-10-31 時点の先頭のコミット)❌ 8 件成功 ・ 10 件失敗(カレントより上を指す 10 件)
Bun 1.4.4-canary.1+94fbd3674(PR #45410 のビルド)❌ 8 件成功 ・ 10 件失敗(カレントより上を指す 10 件)
Bun 1.4.3-canary.1+cfff26e9a(PR #42014 のビルド)❌ 15 件成功 ・ 3 件失敗(./ ・ .\ ・ inner/..)
Bun 1.4.3-canary.1+891eb8df5(PR #42635 のビルド)❌ 15 件成功 ・ 3 件失敗(./ ・ .\ ・ inner/..)
Bun 1.4.1-canary.1+8c04559b9(PR #40536 のビルド)✅ 18 件成功