Description
With directfs enabled, unprivileged runsc over an OCI bundle whose user namespace maps the invoking UID/GID to themselves (without --rootless) fails two operations that succeed on Linux. The files are on a read-write bind mount with shared file access:
- Create a file
O_WRONLY|O_CREAT|O_EXCLwith mode0444, write and close it, then open itO_RDONLY. The read-only open succeeds, butmmap(PROT_READ, MAP_SHARED)returnsENODEVwhile the inode retains the write handle. The earlier ARM64 probe also reproduced this withMAP_PRIVATEand with files written beforechmod(0444). - Create files, make them
0444, and set explicit timestamps as their owner after their cached handles have been evicted.utimensatreturnsEACCES. A loop over 2,000 files reproduces this reliably; with--dcache=0, the earlier probe also reproduced it for a single file.
The same standalone workload succeeds with --directfs=false and when runsc runs as root without a user namespace. The plain Linux workload also succeeds as an unprivileged user on the x86_64 host.
Application impact
The original ARM64 investigation reproduced failures in libgit2 clone/fetch, cargo git dependencies, and Nix fetchers. libgit2 writes its pack index through a write-only lock file created with mode 0444, then opens and maps that index. The mapping failure leads to misleading missing-object errors:
- libgit2:
target OID for the reference doesn't exist on the repository; - cargo:
revision … not found; - Nix:
failed to insert entry: invalid object specified.
The timestamp failure separately breaks Nix store canonicalisation, which makes files read-only and sets their mtime to 1. The remote x86_64 confirmation below reran the standalone reproducer and controls, rather than these application-level cases.
Environment and confirmation
Tested on 2026-10-24 with both release-20261005.0 and release-20261019.0 (spec: 1.2.1), using systrap, direct OCI bundles, and no Docker. The x86_64 release archives were verified against their published SHA-512 checksums.
| Environment | Kernel | Filesystem / identity |
|---|---|---|
| x86_64 KVM VM, Ubuntu 26.04.1 LTS | Linux 7.0.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Aug 1 04:26:38 UTC 2026 x86_64 | ext4 on /dev/sda1; test paths under /var/tmp; unprivileged UID/GID 65534 |
| aarch64 Lima VZ VM on macOS, Ubuntu 26.04 LTS | Linux 7.0.0-38-generic #38-Ubuntu SMP PREEMPT_DYNAMIC Fri Sep 4 09:28:02 UTC 2026 aarch64 | guest /tmp; unprivileged UID 1000, GID 1001 |
Both hosts have kernel.apparmor_restrict_unprivileged_userns=1; a temporary unconfined AppArmor profile granting userns, was loaded for the test runsc paths. No global sysctl was changed. All test sandboxes, temporary binaries, and temporary profiles were removed afterwards.
Results were identical on both architectures and both releases:
| Configuration | Repetitions per release per host | mmap | Explicit timestamps on 2,000 read-only files |
|---|---|---|---|
Rootless, --directfs=true | 2 | ENODEV | 2,000/2,000 EACCES |
Rootless, --directfs=false | 2 | succeeds | succeeds |
Rootful, --directfs=true, no user namespace | 1 | succeeds | succeeds |
Rootless, --directfs=true --dcache=0 | 1 | succeeds | 2,000/2,000 EACCES |
Plain Linux, unprivileged on the x86_64 ext4 host: both operations succeed (one run).
Steps to reproduce
Prerequisites: Linux, Bash, Python 3 at /usr/bin/python3, and an unpacked runsc release with its accompanying gvisor-bin/ directory. Use an absolute runsc path. The invoking user must be allowed to create user namespaces. On Ubuntu with restricted unprivileged user namespaces, an administrator can load an AppArmor profile for that exact runsc executable, for example:
abi <abi/4.0>,
include <tunables/global>
profile gvisor-repro /absolute/path/to/runsc flags=(unconfined) {
userns,
}Load with sudo apparmor_parser -a /path/to/profile; remove after testing with sudo apparmor_parser -R /path/to/profile. Use an appropriate unique profile name if one already exists.
Save the following as gvisor-repro.sh. It uses an existing temporary path for the bind destination, so /data need not exist. Non-root runs add the self-mapped user namespace; root runs omit it. TMPDIR selects the host filesystem under test.
#!/bin/bash
# Usage: bash gvisor-repro.sh /absolute/path/to/runsc [RUNSC_FLAG...]
# Run unprivileged for the failing case. Run as root for the rootful control.
# Requires Linux, Python 3, and runsc with permission to create user namespaces.
# TMPDIR chooses the filesystem under test; use a local Linux filesystem.
set -euo pipefail
RUNSC=${1:?runsc}
shift
t=$(mktemp -d)
cid=lg2repro$$
# runsc delete takes the sandbox's state away; runsc run by root also leaves a network namespace bound in it.
cleanup() {
"$RUNSC" --root="$t/state" delete --force "$cid" >/dev/null 2>&1 || true
if [ "$(id -u)" = 0 ] && findmnt -rn "$t/state/null-netns" >/dev/null 2>&1; then
umount "$t/state/null-netns" || umount -l "$t/state/null-netns"
fi
rm -rf "$t"
}
trap cleanup EXIT
mkdir -p "$t/bundle" "$t/state" "$t/data"
chmod 0755 "$t" "$t/data"
cat >"$t/bundle/repro.py" <<'PY'
import mmap, os
p = "/data/f"
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o444)
os.write(fd, b"x" * 4096)
os.close(fd)
fd = os.open(p, os.O_RDONLY)
try:
mmap.mmap(fd, 4096, flags=mmap.MAP_SHARED, prot=mmap.PROT_READ).close()
print("mmap of a file written O_WRONLY with mode 0444: ok")
except OSError as e:
print(f"mmap of a file written O_WRONLY with mode 0444: {e}")
os.close(fd)
# A store's way: written, made read-only, then its times set once the file is no longer cached (2000 files).
d = "/data/many"
os.mkdir(d)
for k in range(2000):
q = f"{d}/{k}"
fd = os.open(q, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
os.close(fd)
os.chmod(q, 0o444)
bad = 0
for k in range(2000):
try:
os.utime(f"{d}/{k}", (1, 1), follow_symlinks=False)
except OSError as e:
bad += 1
err = e
print("utimensat by the owner of 2000 read-only files: " + ("ok" if not bad else f"{bad} fail: {err}"))
PY
sed -i "s|/data/|$t/data/|g" "$t/bundle/repro.py"
python3 - "$t" "$(id -u)" "$(id -g)" <<'PY'
import json, sys
t, uid, gid = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
spec = {
"ociVersion": "1.0.2",
"process": {"user": {"uid": uid, "gid": gid}, "args": ["/usr/bin/python3", t + "/bundle/repro.py"],
"env": ["PATH=/usr/bin:/bin"], "cwd": "/", "noNewPrivileges": True,
"capabilities": {k: [] for k in ("bounding", "effective", "inheritable", "permitted", "ambient")}},
"root": {"path": "/", "readonly": True},
"mounts": [
{"destination": "/proc", "type": "proc", "source": "proc"},
{"destination": t + "/data", "type": "bind", "source": t + "/data", "options": ["rbind", "rw"]},
{"destination": t + "/bundle", "type": "bind", "source": t + "/bundle", "options": ["rbind", "ro"]},
],
"linux": {"namespaces": [{"type": n} for n in ("pid", "ipc", "uts", "mount")]},
}
if uid != 0:
spec["linux"]["namespaces"].append({"type": "user"})
spec["linux"]["uidMappings"] = [{"containerID": uid, "hostID": uid, "size": 1}]
spec["linux"]["gidMappings"] = [{"containerID": gid, "hostID": gid, "size": 1}]
json.dump(spec, open(t + "/bundle/config.json", "w"), indent=1)
PY
echo "runsc $("$RUNSC" --version | head -1 | cut -d' ' -f3), uid $(id -u), flags: --network=none --ignore-cgroups --host-uds=none --overlay2=none ${*:-}"
"$RUNSC" --root="$t/state" --network=none --ignore-cgroups --host-uds=none --overlay2=none --debug --debug-log="$t/debug.log" --log="$t/runsc.log" "$@" \
run --bundle "$t/bundle" "$cid" </dev/null || {
echo "runsc failed; its log ends:"
cat "$t/runsc.log"
tail -100 "$t"/debug.log*
exit 1
}Run as an ordinary unprivileged user:
bash gvisor-repro.sh /absolute/path/to/runsc --platform=systrap --directfs=true
bash gvisor-repro.sh /absolute/path/to/runsc --platform=systrap --directfs=false
bash gvisor-repro.sh /absolute/path/to/runsc --platform=systrap --directfs=true --dcache=0Rootful control (no user namespace, selected automatically by the script):
sudo bash gvisor-repro.sh /absolute/path/to/runsc --platform=systrap --directfs=trueExpected: both operations print ok in all configurations.
Observed with rootless directfs enabled, on both releases and architectures (temporary pathname abbreviated):
mmap of a file written O_WRONLY with mode 0444: [Errno 19] No such device
utimensat by the owner of 2000 read-only files: 2000 fail: [Errno 13] Permission denied: '…/data/many/1999'The script prints errors from the individual operations, so its exit status alone is not a pass/fail oracle. A runsc startup failure is reported separately and exits nonzero. --dcache=0 avoids the mmap failure in this sequence, but the timestamp failure remains.
Code path and diagnostics
Source inspected at d15d5319; the original investigation also inspected release-20261005.0. These source observations explain the failures, but no proposed patch has been tested.
- The inode retains the write-only host FD from creation. When a read handle is requested,
dentry.ensureSharedHandlefirst tries to acquire one combined read/write handle. directfsInode.openHandleopens the file relative to the parent's control FD under the sentry's credentials. TheO_RDWRopen fails withEACCESfor mode0444in the failing setup.ensureSharedHandlefalls back to separate read/write handles. It sets the read FD, but only setsmmapFDfrom that read-only FD if!d.inode.isWriteHandleOk(). The retained write handle prevents that, leavingmmapFDnegative.regularFileFD.ConfigureMMaprequires a host mmap FD for every mapping inInteropModeShared, and returnsENODEVwhen it is missing.- For explicit timestamps,
directfsInode.prepareSetStatandutimensatrequire a write handle for regular files. Once a cached write handle is gone, obtainingO_WRONLYon the0444file fails withEACCESbefore timestamps can be set. The plain Linux baseline permits the owner to set these explicit times.
The original ARM64 descriptor inspection found the sentry holding one O_WRONLY and two O_RDONLY FDs on the failed mapping's file, with no O_RDWR. With directfs disabled, it held an O_RDWR FD donated by the gofer.
In that original ARM64 inspection, /proc/<sentry>/status showed CapEff: 0000000000000000, despite the boot log naming effective directfs capabilities. The gofer showed CapEff: 000000000004001f. This capability discrepancy is an observation; why the sentry lost those capabilities has not been established, and capabilities were not independently inspected in the remote x86_64 rerun.
A shortened trace from the original libgit2 run (--debug --strace, ARM64) shows the sequence:
openat(…/pack_git2_….idx.lock, O_WRONLY|O_CLOEXEC|O_CREAT|O_EXCL, 0o444) = 5
write(5, …); close(5)
linkat(…idx.lock, …/pack-….idx); unlinkat(…idx.lock)
openat(…/pack-….idx, O_RDONLY|O_CLOEXEC) = 4
gofer.dentry.ensureSharedHandle: bifurcating read/write handles for dentry …
mmap(…, PROT_READ, MAP_SHARED, 4, 0) = -1 errno=19 (no such device)Possible approaches (untested)
- For the mmap symptom, allow read-only/private mappings to use an available readable host FD after handle bifurcation. Writable shared mappings still require a suitable read/write FD. Any change needs to preserve handle coherence and mapping invalidation rules.
- Consider a gofer fallback for denied directfs reopens and timestamp changes. Ordinary directfs files do not automatically have a valid
controlFDLisa, so acquiring one and satisfying locking requirements is part of such a change; the existing mount-point fallback cannot simply be called for every file. - Investigate the rootless sentry's observed capability discrepancy before deciding whether capability retention is an appropriate fix.
The first approach addresses mmap only; it does not fix timestamp updates.
Related issue
#15569 reports overlay copy-up opening the upper file O_WRONLY and reaching the same missing-mmap-FD ENODEV check. Its suggested O_RDWR change to copy-up does not cover an application's own write-only creation or the timestamp failure here. This reproducer uses --overlay2=none and needs neither nested Docker nor overlay copy-up.
Activity
0No activity yet.