fix(sys/windows): return error for unmapped Win32 codes in symlink_w by robobun · Pull Request #32643 · oven-sh/bun · GitHub
Skip to content

fix(sys/windows): return error for unmapped Win32 codes in symlink_w#32643

Open
robobun wants to merge 2 commits into
mainfrom
farm/b5b78dc4/win32-symlink-unmapped-error
Open

fix(sys/windows): return error for unmapped Win32 codes in symlink_w#32643
robobun wants to merge 2 commits into
mainfrom
farm/b5b78dc4/win32-symlink-unmapped-error

Conversation

@robobun

@robobun robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Crash signature

panic: invalid enum value
threading/ThreadPool.zig:581  run
install/isolated_install/Installer.zig:1334  callback
install/isolated_install/Installer.zig:1024  run
install/isolated_install/Symlinker.zig:76  ensureSymlink
install/isolated_install/Symlinker.zig:8  symlink
sys/sys.zig:2758  symlinkOrJunction
sys/sys.zig:2808  symlinkW

Sentry BUN-2Q70 / BUN-2VC5, ~750 events across bun 1.3.10 through 1.3.14, Windows x86_64 only, all during bun install --linker=isolated.

Status of the reported panic

The @tagName(errno) panic at sys.zig:2795 cannot occur on main: the Zig source tree stopped compiling at 23427db (the Rust rewrite, 4 commits after bun-v1.3.14), and the Rust Win32Error is #[repr(transparent)] struct Win32Error(pub u16) with #[derive(Debug)]. Every GetLastError() value round-trips; there is no enum tag to be invalid. So the crash itself is fixed for any post-rewrite release with no code change.

The carried-over behavior bug

Both the Zig symlinkW and its Rust port shared the same fall-through after the log line:

// src/sys/lib.rs, symlink_w, before this change
if rc == 0 {
    let win_err = windows::Win32Error::get();
    ...
    if let Some(sys_errno) = win_err.to_system_errno() {
        ...
        return Err(Error::from_code(e, Tag::symlink));
    }
    // Win32 error without an errno mapping: treat as success
}
return Ok(());

SystemErrno::init_win32_error only maps the ~90 libuv-relevant codes. CreateSymbolicLinkW can fail with many others: filter-driver status codes, network-redirector errors, ReFS limitations, or security software that hooks the call. When it does, to_system_errno() yields None and the function returns Ok(()) even though no symlink was created.

On 1.3.x Zig builds the user never reached this branch because @tagName(errno) panicked first. On main the user would reach it: the isolated-install Symlinker sees success, symlink_or_junction skips its junction fallback, and node_modules is left with missing package links. The install exits 0 and module resolution fails at runtime.

Fix

symlink_w now maps the Win32 error via Win32ErrorExt::to_e(), which falls back to E::UNKNOWN for unmapped codes. That sets the has_failed_to_create_symlink sticky bit (since UNKNOWN is not NOENT/EXIST) and returns an Err, so symlink_or_junction falls through to a junction and subsequent calls go straight to junctions. This matches every other *_w wrapper in the same module (unlink_w, mkdir_w, link_w), which all route through windows::get_last_errno() with the EUNKNOWN fallback.

Win32ErrorUnwrap::unwrap (used after MoveFileExW in bun_resolver) had the identical pattern: non-SUCCESS unmapped code returned Ok(()). It now returns EUNKNOWN.

Verification

cargo check -p bun_sys -p bun_resolver -p bun_install --target x86_64-pc-windows-msvc --tests passes.

#[cfg(all(windows, test))] unit tests added in src/sys/windows/mod.rs:

  • Win32Error::SUCCESS.unwrap() is Ok
  • Win32Error::FILE_NOT_FOUND.unwrap() is Err
  • Win32Error(0xFFFE).unwrap() is Err (was Ok before this change)
  • Win32Error(0xFFFE).to_e() is E::UNKNOWN

Why no integration test

Reproducing requires CreateSymbolicLinkW to return 0 with GetLastError() set to a code outside the ~90-entry init_win32_error table. The common failure modes on a clean Windows CI runner all map: ERROR_PRIVILEGE_NOT_HELD (1314) maps to EPERM, ERROR_NOT_SUPPORTED (50) on FAT32 maps to ENOTSUP, ERROR_ALREADY_EXISTS (183) maps to EEXIST, etc. The unmapped codes observed in the field come from the user's filter-driver / AV / network-redirector stack, which CI does not have. On Linux the code path does not compile (#[cfg(windows)]), so the gate's stash-src fail-before check has nothing to run; the Rust unit tests above run only under cargo test --target x86_64-pc-windows-msvc.

When CreateSymbolicLinkW fails with a Win32 error code that has no entry
in the SystemErrno mapping table, symlink_w previously returned Ok(()) and
the caller believed a symlink had been created. symlink_or_junction then
skipped its junction fallback, and the isolated-install linker left
node_modules with missing links.

This surfaces in the wild on network shares, ReFS volumes, and when
security software hooks CreateSymbolicLinkW and returns driver-specific
codes (Sentry BUN-2Q70 / BUN-2VC5, ~750 events on 1.3.x). The 1.3.x builds
panicked in the Zig @TagName path before reaching this branch; the Rust
port cannot panic there (Win32Error is a newtype) but carried over the
fall-through-to-success behavior.

symlink_w now uses Win32ErrorExt::to_e(), which falls back to E::UNKNOWN
for unmapped codes, sets the has_failed_to_create_symlink sticky bit, and
returns an error so symlink_or_junction tries a junction instead.

Win32ErrorUnwrap::unwrap had the same pattern (non-SUCCESS unmapped code
returned Ok(())); it now returns EUNKNOWN. That path is used by
MoveFileExW in the resolver save-file routine.

Windows-only unit tests added under cfg(all(windows, test)).
@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2eda99d0-f052-4d04-a5d0-a4a73420428a

📥 Commits

Reviewing files that changed from the base of the PR and between 0be8a2d and 0f895db.

📒 Files selected for processing (2)
  • src/sys/lib.rs
  • src/sys/windows/mod.rs

Walkthrough

Two Windows error-handling fixes: Win32ErrorUnwrap::unwrap now maps unrecognized non-SUCCESS Win32 codes to SystemErrno::EUNKNOWN instead of returning Ok(()), and symlink_w always propagates CreateSymbolicLinkW failures as errors rather than silently passing through unmapped codes. Unit tests are added for the unwrap behavior.

Changes

Win32 Error Handling Fix

Layer / File(s) Summary
Win32ErrorUnwrap::unwrap unmapped code fix and tests
src/sys/windows/mod.rs
unwrap() now calls to_system_errno().unwrap_or(SystemErrno::EUNKNOWN) so unmapped non-SUCCESS codes return Err instead of Ok(()). New unit tests cover success, mapped (FILE_NOT_FOUND), and unmapped codes, verifying to_e() returns E::UNKNOWN for unmapped inputs.
symlink_w always propagates CreateSymbolicLinkW failures
src/sys/lib.rs
Error path in symlink_w now calls to_e() for all failures, sets the sticky symlink-failed flag for any result other than ENOENT/EEXIST, and unconditionally returns Err(Error::from_code(e, Tag::symlink)) instead of falling through on unmapped Win32 codes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing Windows symlink error handling for unmapped Win32 error codes.
Description check ✅ Passed The description provides comprehensive context including crash signature, root cause analysis, fix details, and verification steps, fully satisfying the template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. bun install reports success but creates empty package dirs on OneDrive-mounted Windows paths #30264 - OneDrive's filesystem virtualization returns non-standard Win32 error codes from CreateSymbolicLinkW, causing silent success and empty package dirs — this PR would surface the failure and trigger the junction fallback
  2. Windows: bun link fails on mapped drives #19575 - Mapped network drives return unmapped Win32 codes during bun link, hitting the same silent-success fall-through in symlink_w that this PR fixes
  3. Bun broken with subst (Windows) #20568 - subst-mapped virtual drives are another environment where CreateSymbolicLinkW can return non-standard Win32 codes, causing silent install failure

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #30264
Fixes #19575
Fixes #20568

🤖 Generated with Claude Code

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Looked at the three issues the bot suggested:

  • bun install reports success but creates empty package dirs on OneDrive-mounted Windows paths #30264 (OneDrive, empty package dirs): possibly related. OneDrive's Files On-Demand filter driver can return unusual Win32 codes from CreateSymbolicLinkW. But the default Windows install backend is hardlink, and link_w already routes unmapped codes through get_last_errno()EUNKNOWN, so a plain bun install on 1.3.13 wouldn't hit the symlink_w fall-through unless the user set --backend=symlink or is linking local/workspace packages. Leaving this as a possible related issue rather than a claimed fix; would need a OneDrive repro to confirm.
  • Windows: bun link fails on mapped drives #19575 (bun link on mapped network drive): already surfaces EUNKNOWN to the user, so it's not the silent-success bug. What's missing there is a junction/copy fallback in the bun link path, which is a separate change.
  • Bun broken with subst (Windows) #20568 (subst drives): unrelated. That crash is a path-resolution assertion in Package.parseWithJSON where top_level_dir is the subst drive letter and source.path.name.dir is the realpath; nothing to do with CreateSymbolicLinkW.

Not adding Fixes lines for any of these.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward error-handling fix that stops unmapped Win32 codes from being treated as success, matching the existing get_last_errno() pattern.

Extended reasoning...

Overview

This PR touches two files in the Windows syscall layer: src/sys/lib.rs (the symlink_w wrapper) and src/sys/windows/mod.rs (Win32ErrorUnwrap::unwrap, plus new unit tests). In both places, the previous code called to_system_errno() and, when the Win32 error code had no entry in the ~90-entry errno mapping table, fell through to Ok(()) — reporting success for a Win32 call that had returned a failure status. The fix routes unmapped codes to E::UNKNOWN / SystemErrno::EUNKNOWN instead, so the caller sees an error and (in the symlink_or_junction case) falls back to creating a junction.

Security risks

None. This is pure error-code mapping in the filesystem syscall wrappers; no auth, crypto, input parsing, or trust-boundary changes. The change strictly tightens behavior (failure → reported as failure) rather than loosening it.

Level of scrutiny

Low-to-moderate. The diff is ~15 lines of logic plus tests, Windows-only, and brings two outliers in line with the established convention already used by windows::get_last_errno() (src/sys/windows/mod.rs:3308) and the other *_w wrappers. I verified that Win32ErrorExt::to_e() (src/errno/windows_errno.rs:867) does indeed fall back to E::UNKNOWN, and that the only external caller of Win32ErrorUnwrap::unwrap is the MoveFileExW path in src/resolver/lib.rs, where surfacing the error rather than swallowing it is clearly the correct behavior.

Other factors

The bug hunting system found no issues. The PR includes targeted #[cfg(test)] unit tests covering SUCCESS, a mapped error, and an unmapped error (0xFFFE), and the description gives a clear rationale for why an integration test is impractical (requires filter-driver/AV-injected error codes not reproducible in CI). No outstanding reviewer comments. The change is small, self-contained, and obviously correct given that rc == 0 from CreateSymbolicLinkW always means failure regardless of whether we can name the errno.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward error-path fix that aligns symlink_w and Win32ErrorUnwrap::unwrap with the existing to_e()E::UNKNOWN fallback pattern.

Extended reasoning...

Overview

This PR touches two spots in the Windows syscall layer:

  • src/sys/lib.rs (symlink_w): replaces an if let Some(sys_errno) = win_err.to_system_errno() that fell through to Ok(()) on unmapped Win32 codes with an unconditional win_err.to_e()Err(...), where to_e() already falls back to E::UNKNOWN.
  • src/sys/windows/mod.rs (Win32ErrorUnwrap::unwrap): same shape — non-SUCCESS unmapped codes now yield Err(EUNKNOWN) instead of Ok(()).
  • Adds 4 small #[cfg(test)] unit tests covering success/mapped/unmapped cases.

I cross-checked Win32ErrorExt::to_e() at src/errno/windows_errno.rs:867-871 and confirmed it does .unwrap_or(E::UNKNOWN), and symlink_or_junction at src/sys/lib.rs:8762-8769 falls through to the libuv junction on any error other than EXIST/NOENT — so the new E::UNKNOWN return correctly triggers the junction fallback and sets the sticky bit, exactly as the PR description claims.

Security risks

None. This is purely an error-propagation change in the Windows filesystem wrapper layer. It makes behavior strictly more conservative: a Win32 API failure that previously returned Ok(()) now returns Err. There is no new input parsing, no auth/crypto/permission logic, and no externally-controlled data flow.

Level of scrutiny

Low-to-moderate. The diff is ~20 lines of logic, Windows-only (#[cfg(windows)]), and follows the exact pattern already used by unlink_w, mkdir_w, link_w, and translate_ntstatus_to_errno (all route through to_e() / get_last_errno() with the EUNKNOWN fallback). The change is mechanical: drop a fall-through-to-success branch that was clearly a porting artifact from the Zig version (where it panicked before reaching the fall-through).

Other factors

  • Bug hunting system found no issues.
  • Unit tests added that pin the new behavior (Win32Error(0xFFFE).unwrap().is_err(), to_e() == E::UNKNOWN).
  • The PR description includes a thorough root-cause analysis tied to Sentry crash reports and a clear explanation of why an integration test is impractical (requires filter-driver/AV-injected error codes not present on CI).
  • No outstanding reviewer comments; CodeRabbit had no actionable findings.
  • Caller (symlink_or_junction) already handles the new error gracefully via the junction fallback, so no downstream breakage.

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant