Skip to content

Windows host: unimplemented FUSE opcodes are answered with the *host's* ENOSYS (40), which the Linux guest reads as ELOOP - every Rust binary in the guest breaks on virtiofs mounts #1219

Description

@darrensmithwtc

Summary

On a Windows host, ls inside any sandbox with a bind mount fails with Too many levels of symbolic links (errno 40), and ls -la / shows d????????? for every virtiofs mount — including msb's own /.msb runtime share.

The mount itself is fine: find, cat via shell redirect, and [ -d ] all work on the same mount at the same moment.

The cause is that the host→Linux errno translation module is stubbed out on Windows, so the device layer sends host errno values into the guest's Linux errno space. The specific fatal collision: an unimplemented opcode is reported with libc::ENOSYS, evaluated in the host's errno space:

ENOSYS ELOOP
Linux (the guest, always) 38 40
Windows (libc::windows) 40 114
macOS (libc::apple) 78 62

So on Windows the guest receives 40 and interprets it as ELOOP. The unimplemented opcode is FUSE_STATX (52, Linux 6.6+) — the Opcode enum stops at RemoveMapping = 49. Because the guest kernel only sets fc->no_statx and falls back to GETATTR on -ENOSYS, it never falls back; the bogus ELOOP goes straight to userspace, permanently.

Ubuntu 25.10+ ships uutils coreutils (Rust), so ls and cat are broken there. GNU coreutils (Debian) use fstatat and work fine.

Reproduction

Windows 11 host, msb 0.6.7 (also reproduces on 0.6.6):

# Repro: statx -> ELOOP on virtiofs mounts, Windows hosts.
# Run on a Windows host with `msb` on PATH. Nothing is installed or modified
# outside the temp directory this creates.

$ErrorActionPreference = "Continue"

function Section($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }

Section "environment"
msb --version
Write-Host "host: $([System.Environment]::OSVersion.VersionString)"

Section "1. no mount required - msb's own /.msb share is unstattable"
Write-Host "expect: '.msb' listed as d?????????  (statx failed)"
msb run ubuntu -- ls -la /

$dir = Join-Path $env:TEMP "msb-repro-statx"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Set-Content -Path (Join-Path $dir "f.txt") -Value "hello-from-host" -NoNewline

Section "2. same mount, four syscall paths (ubuntu / uutils / statx)"
Write-Host "expect: the first three succeed, 'ls' fails with FilesystemLoop"
msb run --mount-dir "${dir}:/w" ubuntu -- sh -c "[ -d /w ] && echo 'newfstatat: OK'"
msb run --mount-dir "${dir}:/w" ubuntu -- find /w -maxdepth 1
msb run --mount-dir "${dir}:/w" ubuntu -- sh -c "read l < /w/f.txt; echo `"read: `$l`""
msb run --mount-dir "${dir}:/w" ubuntu -- ls -la /w

Section "3. same mount, GNU coreutils instead of uutils"
Write-Host "expect: debian lists the directory cleanly - the mount was always fine"
msb run --mount-dir "${dir}:/w" debian -- ls -la /w

Section "done"
Write-Host "Mount used: $dir"
Write-Host "If step 3 works and step 2's 'ls' does not, the mount is healthy and only statx fails."

What should you see?

=== environment ===
msb 0.6.7
host: Microsoft Windows NT 10.0.26200.0

=== 1. no mount required - msb's own /.msb share is unstattable ===
expect: '.msb' listed as d?????????  (statx failed)

total 76
drwxr-xr-x  1 root root 4096 Jan  1  2020 .
drwxr-xr-x  1 root root 4096 Jan  1  2020 ..
d?????????  ? ?    ?       ?            ? .msb
drwxr-xr-x  2 root root   52 Jul 13 16:06 .rock
lrwxrwxrwx  1 root root    7 Apr 20 08:46 bin -> usr/bin
drwxr-xr-x  2 root root   27 Apr 20 08:46 boot
drwxr-xr-x  8 root root 2360 Jan  1  2020 dev
drwxr-xr-x  1 root root 4096 Jul 13 16:06 etc
drwxr-xr-x  3 root root   45 Jul 13 16:06 home
lrwxrwxrwx  1 root root    7 Apr 20 08:46 lib -> usr/lib
lrwxrwxrwx  1 root root    9 Apr 20 08:46 lib64 -> usr/lib64
drwxr-xr-x  2 root root   27 Jul 13 16:05 media
drwxr-xr-x  2 root root   27 Jul 13 16:05 mnt
drwxr-xr-x  2 root root   27 Jul 13 16:05 opt
dr-xr-xr-x 98 root root    0 Jan  1  2020 proc
drwx------  2 root root   66 Jul 13 16:06 root
drwxr-xr-x  1 root root 4096 Jan  1  2020 run
lrwxrwxrwx  1 root root    8 Apr 20 08:46 sbin -> usr/sbin
drwxr-xr-x  2 root root   27 Jul 13 16:05 srv
dr-xr-xr-x 11 root root    0 Jan  1  2020 sys
drwxrwxrwt  2 root root   40 Jan  1  2020 tmp
drwxr-xr-x 12 root root  194 Jul 13 16:05 usr
drwxr-xr-x 11 root root  204 Jul 13 16:06 var

=== 2. same mount, four syscall paths (ubuntu / uutils / statx) ===

expect: the first three succeed, 'ls' fails with FilesystemLoop

newfstatat: OK
/w
/w/f.txt
read:
ls: unknown io error: '/w', 'Os { code: 40, kind: FilesystemLoop, message: "Too many levels of symbolic links" }'

=== 3. same mount, GNU coreutils instead of uutils ===
expect: debian lists the directory cleanly - the mount was always fine
total 5
drwxrwxrwx 1 root root    0 Jul 27 08:50 .
drwxr-xr-x 1 root root 4096 Jan  1  2020 ..
-rwxrwxrwx 1 root root   15 Jul 27 08:50 f.txt

=== done ===
Mount used: C:\Users\<snip>\AppData\Local\Temp\msb-repro-statx
If step 3 works and step 2's 'ls' does not, the mount is healthy and only statx fails.

Root cause

**In the libkrun fork (msb_krun_devices 0.1.25 on crates.io, superradcompany/libkrun). The Windows FUSE path and the stub below are additions in the fork.

virtio/mod.rs:31-40 compiles the errno translation table on every platform except Windows, where it is replaced by a no-op stub:

#[cfg(not(target_os = "windows"))]
pub mod linux_errno;                      // real host -> Linux table
                                          // (maps libc::ENOSYS => LINUX_ENOSYS = 38)

#[cfg(target_os = "windows")]
pub mod linux_errno {
    pub const LINUX_ERANGE: i32 = 34;

    pub fn linux_error(error: std::io::Error) -> std::io::Error {
        std::io::Error::from_raw_os_error(error.raw_os_error().unwrap_or(5))
    }
}

So on Windows every linux_error(...) call in the FUSE server returns the host value unchanged. The fatal one is virtio/fs/server.rs:198:

_ => reply_error(
    linux_error(io::Error::from_raw_os_error(libc::ENOSYS)),
    in_header.unique,
    w,
),

libc::ENOSYS is 40 on Windows (libc-0.2.186/src/windows/mod.rs:182) and 40 is ELOOP on Linux. On a Linux host the real table maps it to 38 and the kernel's documented fallback works, which is why this has never been seen.

Opcode (virtio/fs/fuse.rs:683-731) ends at RemoveMapping = 49 and has no Statx = 52, so every statx from the guest takes that fallback arm.

The blast radius is wider than statx.

Every errno the device layer sends is in the wrong number space on Windows. Values 1-32 happen to agree between the Windows CRT and Linux, which is why ordinary I/O works and only the high range is scrambled — e.g. a genuine ENAMETOOLONG (38 on Windows) would reach the guest as ENOSYS, and ELOOP (114 on Windows) as EALREADY.

Worse, io::Error::raw_os_error() on Windows usually carries a Win32 code rather than a CRT errno, so error paths that propagate a real API failure send values from a third numbering entirely:

ERROR_ACCESS_DENIED (5) arrives as EIO, ERROR_INVALID_DATA (13) as EACCES, ERROR_SHARING_VIOLATION (32) as EPIPE.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions