recursive: true を付けた fs.mkdir は、フォルダが既にあればエラーにせずに返る(Node.js の仕様)。Windows の Bun では、. ・ .. を畳んだパスが .. で始まり(カレントより上を指し)、フォルダが既にあるときに EEXIST になる。カレント自身を指すパス(. ・ ./ ・ inner/..)でも失敗する。Node.js と Deno では起きない。
| 項目 | 値 |
|---|---|
| OS | Windows 11 Home(10.0.26300)x64 |
| Bun | 1.4.3(c6da4a4d3)。bun upgrade で最新であることを確認 |
| Node.js | v26.11.1(v26.10.0 でも同じ結果) |
| Deno | 2.9.7。deno upgrade で最新であることを確認 |
カレントを sub にして、sub と同じ階層の exists(既にある)と new-dir(まだ無い)を指定した。
| API ・ 渡したパス | Bun 1.4.3 | Node.js v26.11.1 | Deno 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
. ・ .. を畳んだパスが .. で始まり(カレントより上を指し)、フォルダが既にあるときだけ。区切りは / でも \ でも起きる.. でなくても起きる。./../exists も、途中の .. で上に出る inner/../../exists も失敗する。エラーメッセージのパスは、畳んだ形(..\exists)になるinner/../inner ・ ./inner ・ inner)と絶対パスでは起きない. は EEXIST: file already exists, mkdir '.'、末尾に区切りが付く ./ ・ .\ と、畳むとカレントになる inner/.. は ENOENT: no such file or directory, mkdir(パスが空のまま出る)になる。inner/. は通るwarn: internal error: OBJECT_NAME_INVALID の行は、1.4.3 では出なかったpath.resolve() で絶対パスにしてから渡す同じ症状の issue が既にあり、修正の PR も出ている。新しい issue は立てず、既存の issue にコメントを付けた(2026-10-12 に投稿したコメント)。
bug windows node:fs)。報告は fs/promises の mkdir だけ. ・ .. の EEXIST だけを扱う./, .\ or sub/.. throws ENOENT with an empty path(2026-10-12 作成、OPEN、ラベル bug windows node:fs)。このプロジェクトから投稿したFixes #45059 とあり、.. で始まるパスは #42014 に任せると書いてある.. を畳んでから処理するchild\.. の ENOENT は残り、#40536 が扱う」とあるFixes #17332 があるPR の説明によると、次の流れになる。
fs.mkdir は CreateDirectoryW を呼び、EEXIST が返ると、そこにあるものがフォルダかを directory_exists_at_w で調べるto_nt_path は区切りを変えるだけで、. ・ .. を解決しない。NT は .. を文字どおりの名前として扱うので、確認が失敗するEEXIST が返る. ・ .. を字面で畳む。畳んだ結果の先頭に残る .. は、元のフォルダのパスが無いと畳めないので、そのまま NT に渡るこの説明は、今回の結果と合う。inner/../inner は畳むと inner になって通り、inner/../../exists は畳むと ..\exists になって失敗する。
先頭コミット cfff26e9a の差分を読んだ。直しているのは src/paths/classify.rs と src/sys/lib.rs の 2 つで、残りはテスト。
classify.rs: パスの部品を数える classify_rel_t に、has_dot_component(ちょうど . か .. の部品がある)を足した。a.b ・ ... は名前として扱うlib.rs: 新しい関数 nt_object_name_at ・ nt_object_name_at_w を足した。. ・ .. の部品があれば、normalize_path_windows でフォルダのハンドルを基準に解決し、無ければ従来どおり to_nt_path で区切りを変えるだけにするmkdirat ・ unlinkat ・ renameat(移動先)・ exists_at_type ・ exists_at_type_w で使うよう置き換えた。フォルダの確認(directory_exists_at_w)は exists_at_type_w を通るので、ここで直るexists_at_type_nt にあった、先頭の .\ を削る処理は消した(解決で要らなくなる).. は、コメントを「mkdirat が解決する」に書き換えただけfs-mkdir.test.ts)のパスは . ・ .. ・ ../cwd ・ ../cwd/sub ・ ./sub ・ ファイル。末尾に区切りが付く ./ と、畳むとカレントになる sub/.. は入っていない読んだ内容は、PR の説明と合う。
PR の CI(Buildkite #113149)が作った Windows x64 のビルド bun-windows-x64.zip を取得した。SHA-256 は一覧の値と一致し、版は 1.4.3-canary.1+cfff26e9a(PR の先頭コミット)だった。このビルドで、カレントより上を指す場合と . はすべて通った。カレント自身を指す ./ ・ .\ ・ inner/.. の 3 件は、このビルドでも ENOENT になる(再現の手順 の表)。原因は次の「ENOENT の原因」。
同じ手順で、#40536 と #42635 の Windows x64 のビルドも取得した。どちらも SHA-256 は一覧の値と一致した。
| PR | ビルド | カレントより上 | . | ./ ・ .\ ・ inner/.. |
|---|---|---|---|---|
| #45410 | Buildkite #127041、1.4.4-canary.1+4a119e097 | ❌ EEXIST | ✅ | ✅ |
| #45410 | Buildkite #126187、1.4.4-canary.1+94fbd3674 | ❌ EEXIST | ✅ | ✅ |
| #40536 | Buildkite #106239、1.4.1-canary.1+8c04559b9 | ✅ | ✅ | ✅ |
| #42635 | Buildkite #115200、1.4.3-canary.1+891eb8df5 | ✅ | ✅ | ❌ ENOENT |
| #42014 | Buildkite #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 のビルドのままである。
Bun 1.4.3(c6da4a4d3)の src/runtime/node/types.rs にある os_path_kernel32 が、mkdir に渡す前にパスを字面で正規化する(normalize_string_buf)。
./ ・ .\ ・ inner/.. は空の文字列になる。ソースのコメントにも「normalizeStringBuf strips it to an empty string」とある. の 1 文字だけを特別に扱い、. のまま渡している。./ などはこの判定に当たらず、空のまま mkdir に渡る..\exists)が出るのも、同じ正規化のため直し方の候補: 正規化の結果が空なら . にする(今の . の特別扱いを、正規化の後ろへ移す)。PR #45410 は別の道を取り、空になった結果を getcwd で得たカレントの絶対パスに置き換える。PR の説明は、. にすると ./ が ENOENT から EEXIST に変わるだけだと書いている(NT の確認が . を解決できないため)。. にする案は、こちらでは試していない。
| PR | 変えているところ | ENOENT の 3 件 |
|---|---|---|
| #45410 | os_path_kernel32 そのものを変える。正規化した相対パスが空で、元の入力が空でないとき、getcwd で得たカレントを使う。ちょうど . だけの特別扱いは外す。existsSync も同じ関数を通る | ✅ 空の文字列が kernel32 に渡らない。❌ カレントより上を指すパスには手を付けていない(PR は #42014 に任せている) |
| #40536 | node_fs.rs の recursive mkdir の入口で、相対パスをカレントと結合して(join_cwd_windows)から正規化する | ✅ 結合したあとなので、正規化しても空にならず、カレントの絶対パスになる |
| #42635 | 既にあるかの確認を、NT の API から libuv の Win32 の stat(stat_w)に替える | ❌ 確認の前の、os_path_kernel32 の正規化は変えていない |
| #42014 | NT に渡す名前の . ・ .. を、フォルダのハンドルを基準に解決する(nt_object_name_at) | ❌ 同じく、os_path_kernel32 の正規化は変えていない |
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 件成功 |