recursive mkdir の ENOENT

Windows の Bun で、"./" ・ ".\" ・ "sub/.." を recursive mkdir すると ENOENT になる。修正 PR の #42014 と #42635 では直らない

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

概要

2026-10-12 に oven-sh/bun#45059 として投稿した。

再現コード: research/repro-enoent.cjs。同じケースは tests/mkdir-recursive.test.ts にもある。関連する EEXIST の不具合の詳細は 調査結果。

エビデンス: 実行結果

環境とビルド

Windows 11 Home 10.0.26300 x64。PR のビルドは、各 PR の Buildkite の CI が作った bun-windows-x64.zip。SHA-256 は、どれも Buildkite の一覧の値と一致した。

ランタイム版入手元
Bun 1.4.31.4.3+c6da4a4d3正式版(bun upgrade)
PR #420141.4.3-canary.1+cfff26e9aBuildkite #113149, SHA-256 6f3415d871f3645620b01e22f27eb708a7267510277dd96a4816d455d5c8323e
PR #426351.4.3-canary.1+891eb8df5Buildkite #115200, SHA-256 6621af0f0afabbcfe8036403e0ff70a341eae63b741be8389992edfed019c0d2
PR #405361.4.1-canary.1+8c04559b9Buildkite #106239, SHA-256 8c8e13a081f26b85e9c0c11c65a2be8687237d02e61e669fb71ce04c5b164d95
PR #454101.4.4-canary.1+94fbd3674Buildkite #126187, SHA-256 8a0b522fb15843a3d38ddd019c179894c961dcfdf79c083d2f02a5c0522b79ee(SHA-1 は Buildkite が示す値と一致した)
PR #45410(2026-10-31 時点の先頭)1.4.4-canary.1+4a119e097Buildkite #127041, SHA-256 02ef8f1b6d7a9cf6d7f5340c36fb21917e7001118096a60cf92fb540cd1815f8(Buildkite が示す値と一致した。結果は 94fbd3674 と同じ)
Node.jsv26.11.1正式版
Deno2.9.7正式版

結果

mkdirSync ・ fs.promises.mkdir ・ コールバックの fs.mkdir で、同じ結果になる。

パスBun 1.4.3PR #42014PR #42635PR #40536PR #45410Node.jsDeno
.❌ EEXIST✅✅✅✅✅✅
./❌ ENOENT❌ ENOENT❌ ENOENT✅✅✅✅
.\❌ ENOENT❌ ENOENT❌ ENOENT✅✅✅✅
sub/..❌ ENOENT❌ ENOENT❌ ENOENT✅✅✅✅
sub/.✅✅✅✅✅✅✅

repro-enoent.cjs の出力(そのまま)

Bun 1.4.3:

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 sub/.. ENOENT ENOENT: no such file or directory, mkdir
OK sub/.
NG promises . EEXIST
NG callback . EEXIST
NG promises ./ ENOENT
NG callback ./ ENOENT
NG promises .\ ENOENT
NG callback .\ ENOENT
NG promises sub/.. ENOENT
NG callback sub/.. ENOENT
OK promises sub/.
OK callback sub/.

PR #42014 と PR #42635(出力は同じ):

OK .
NG ./ ENOENT ENOENT: no such file or directory, mkdir
NG .\ ENOENT ENOENT: no such file or directory, mkdir
NG sub/.. ENOENT ENOENT: no such file or directory, mkdir
OK sub/.
OK promises .
OK callback .
NG promises ./ ENOENT
NG callback ./ ENOENT
NG promises .\ ENOENT
NG callback .\ ENOENT
NG promises sub/.. ENOENT
NG callback sub/.. ENOENT
OK promises sub/.
OK callback sub/.

PR #40536 ・ PR #45410 ・ Node.js ・ Deno(出力は同じ):

OK .
OK ./
OK .\
OK sub/..
OK sub/.
OK promises .
OK callback .
OK promises ./
OK callback ./
OK promises .\
OK callback .\
OK promises sub/..
OK callback sub/..
OK promises sub/.
OK callback sub/.

エビデンス: ソース

パスが空になる箇所

src/runtime/node/types.rs L1072-L1088(Bun 1.4.3、c6da4a4d3)。os_path_kernel32 の、相対パスを扱う部分:

            // Handle "." specially since normalizeStringBuf strips it to an empty string
            if s.len() == 1 && s[0] == b'.' {
                // SAFETY: see alignment note above (PathBuffer reinterpreted as [u16]).
                let buf_u16 = unsafe { bun_core::bytes_as_slice_mut::<u16>(&mut buf[..]) };
                return Ok(strings::to_kernel32_path(buf_u16, b"."));
            }
            let normal = bun_paths::resolve_path::normalize_string_buf::<
                true,
                bun_paths::platform::Windows,
                false,
            >(s, &mut b[..]);
            if !strings::fits_in_wide_path_buffer(normal) {
                return Err(NameTooLong);
            }
            // SAFETY: see alignment note above (PathBuffer reinterpreted as [u16]).
            let buf_u16 = unsafe { bun_core::bytes_as_slice_mut::<u16>(&mut buf[..]) };
            return Ok(strings::to_kernel32_path(buf_u16, normal));

各 PR で直る・直らない理由

PR変えているところENOENT の件
#42014NT に渡す名前の . ・ .. を、フォルダのハンドルを基準に解決する(src/sys/lib.rs の nt_object_name_at)❌ os_path_kernel32 は変えていない
#42635既にあるかの確認を、libuv の Win32 の stat(stat_w)に替える❌ os_path_kernel32 は変えていない
#40536src/runtime/node/node_fs.rs の recursive mkdir の入口で、相対パスをカレントと結合して(join_cwd_windows)から正規化する✅ 結合したパスは、正規化しても空にならず、カレントになる
#45410os_path_kernel32 そのものを変える。正規化した相対パスが空で、元の入力が空でないとき、getcwd で得たカレントを使う。ちょうど "." だけの特別扱いは外す✅ 空の文字列が kernel32 に渡らない。.. で始まる EEXIST の件は #42014 に任せている

#42635 の説明にも、その PR で child\.. の ENOENT は残り、#40536 が扱う、とある。#40536 は 1.4.1 を元にしていて、2026-08-26 から更新がない。