[FIX] rm -rf followed a bind mount into the host's /dev

make_rootfs wiped the rootfs with `rm -rf "$ROOT"` and no check on what was
mounted under it. A diagnostic session had left `mount --rbind /dev "$ROOT/dev"`
in place; the next run deleted straight through it into the real /dev. Every
device node went -- zero, full, random, urandom, tty, console -- and /dev/null
came back later as a regular file created by a redirection, which broke every
`> /dev/null` on the machine and stopped the pass at its first pacman call.

Two guards now, because unmounting alone is not enough: umount_chroot, then
findmnt to CHECK, then refuse. A stale mount is a reason to stop, never a thing
to delete through.

/dev is also no longer written to at all. The bind became --rbind with
--make-rslave, which brings the host's /dev/shm along as the tmpfs it already is
-- so the separate tmpfs and the chmod that reached the host are both gone. That
chmod was the reason this driver could touch /dev in the first place.

The plain --bind was itself the original bug: it does not carry submounts, so the
chroot saw /dev/shm as an empty 0755 stub, CPython's configure got EACCES from
sem_open, and python was built without POSIX semaphores.

--- FR ---

make_rootfs effaçait le rootfs par `rm -rf "$ROOT"` sans vérifier ce qui était
monté dessous. Une session de diagnostic avait laissé `mount --rbind /dev
"$ROOT/dev"` en place ; l'exécution suivante a supprimé au travers, dans le /dev
réel. Tous les nœuds de périphériques ont disparu — zero, full, random, urandom,
tty, console — et /dev/null est revenu en fichier ordinaire créé par une
redirection, ce qui a cassé tout `> /dev/null` sur la machine et arrêté la passe
à son premier appel à pacman.

Deux gardes désormais, car démonter ne suffit pas : umount_chroot, puis findmnt
pour VÉRIFIER, puis refuser. Un montage résiduel est une raison de s'arrêter,
jamais une chose à traverser.

Et /dev n'est plus écrit du tout. Le bind devient --rbind avec --make-rslave, ce
qui apporte le /dev/shm de l'hôte tel qu'il est déjà — un tmpfs — donc le tmpfs
séparé et le chmod qui atteignait l'hôte disparaissent tous deux. Ce chmod était
la raison pour laquelle ce pilote pouvait toucher /dev.

Le --bind simple était lui-même le défaut d'origine : il ne porte pas les
sous-montages, le chroot voyait donc /dev/shm comme une souche vide en 0755, le
configure de CPython recevait EACCES de sem_open, et python était bâti sans
sémaphores POSIX.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 01:14:35 -04:00
parent c43289c5ca
commit 0da64c9933

View file

@ -145,6 +145,24 @@ SigLevel = Never
[core]
Server = file://$REPO1
EOF
# NOTHING MAY BE MOUNTED UNDER $ROOT WHEN THIS RUNS.
#
# `rm -rf` follows a bind mount and deletes what is on the other side. This
# function had no such guard, and the cost was the host's /dev: a diagnostic
# session left `mount --rbind /dev "$ROOT/dev"` in place, the next run wiped
# the rootfs, and the delete went through the mount into the real /dev. Every
# device node went -- zero, full, random, urandom, tty, console -- and
# /dev/null came back later as a regular file created by a redirection, which
# broke every `> /dev/null` on the machine.
#
# Two guards, because the first one is not enough: unmount, then CHECK, then
# refuse. A stale mount is a reason to stop, never a thing to delete through.
umount_chroot
if findmnt -rno TARGET | grep -qF "$ROOT/"; then
printf 'still mounted under %s:\n' "$ROOT" >&2
findmnt -rno TARGET | grep -F "$ROOT/" | sed 's/^/ /' >&2
die "refusing to rm -rf through a mount"
fi
sudo rm -rf "$ROOT" "$CACHE"
sudo mkdir -p "$ROOT/var/lib/pacman" "$CACHE"
# EVERYTHING in stage 1, not a hand-picked core.
@ -516,71 +534,41 @@ mount_chroot() {
# /dev/pts is not optional: without it any build step that opens a pty --
# and gcc's testsuite driver does -- fails in a way that names the pty and
# not the missing mount.
for m in proc sys dev dev/pts dev/shm; do
for m in proc sys dev; do
sudo mkdir -p "$ROOT/$m"
done
mountpoint -q "$ROOT/proc" || sudo mount -t proc proc "$ROOT/proc"
mountpoint -q "$ROOT/sys" || sudo mount -t sysfs sys "$ROOT/sys"
mountpoint -q "$ROOT/dev" || sudo mount --bind /dev "$ROOT/dev"
mountpoint -q "$ROOT/dev/pts" || sudo mount -t devpts devpts "$ROOT/dev/pts"
# /dev/shm, for the same reason /dev/pts needs its own line: `mount --bind
# /dev` does NOT carry submounts, and both of these are separate mounts on
# the host.
# /dev by --rbind, then --make-rslave. Not a plain --bind, and nothing under
# it is ever modified.
#
# nss failed with
# A plain --bind does NOT carry submounts, so the chroot saw /dev/shm as the
# empty 0755 stub inside devtmpfs rather than the host's tmpfs. That cost
# three passes: CPython runs sem_open at configure time, got EACCES, decided
# the platform has no POSIX semaphores, and compiled _multiprocessing without
# SemLock -- after which every consumer failed with a message blaming the
# platform, days after the cause.
#
# ImportError: This platform lacks a functioning sem_open implementation.
# https://github.com/python/cpython/issues/48020
# The first fix mounted a separate tmpfs at $ROOT/dev/shm and chmod'ed it.
# That was wrong in a way worth recording: $ROOT/dev was a bind of the HOST's
# /dev, so writes under it reach the real /dev -- and the host's /dev/null
# ended up replaced by a regular file, which broke every redirection on the
# machine until it was recreated with mknod.
#
# multiprocessing.Semaphore is built on POSIX named semaphores, which glibc
# implements as files under /dev/shm. Without the mount, sem_open returns
# ENOSYS and CPython reports it as the PLATFORM lacking the feature -- which
# reads like an s390x limitation and is a missing tmpfs.
#
# A tmpfs of its own rather than a bind: nothing here shares semaphores with
# anything outside the chroot. Mode 1777 because unprivileged builds write
# into it.
#
# AND IT MUST BE HERE BEFORE PYTHON IS BUILT. Mounting it later does not
# help: CPython's configure runs sem_open at BUILD time, and with no /dev/shm
# it concluded the platform has no working semaphores --
#
# POSIX_SEMAPHORES_NOT_ENABLED: 1 (in _sysconfigdata__*.py)
# HAVE_SEM_OPEN: 1 (the function exists)
#
# -- and compiled _multiprocessing without SemLock. Any package that imports
# multiprocessing then fails permanently, with a message blaming the
# platform:
#
# ImportError: This platform lacks a functioning sem_open implementation.
#
# nss is the one that found it. A python built in a chroot without this mount
# stays broken however many times the consumer is retried; the fix is to
# rebuild python, which is why this comment says so.
mountpoint -q "$ROOT/dev/shm" ||
sudo mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs "$ROOT/dev/shm"
# The MODE, set explicitly and not left to the mount option.
#
# `-o mode=1777` only applies when this mount is created. The guard above
# skips an existing one -- and an existing one may have been mounted without
# the option, which is exactly what happened: findmnt showed the tmpfs there
# while the directory was drwxr-xr-x, so uid 1000 could create nothing in it.
#
# That is what CPython's configure actually hit:
#
# sem_open: Permission denied
# checking whether POSIX semaphores are enabled... no
#
# EACCES, not ENOSYS. The port spent a pass reading it as a missing mount and
# another as a missing tmpfs, when the mount was present and the mode wrong.
sudo chmod 1777 "$ROOT/dev/shm"
# And PROVED from inside, as the build user, because every previous check of
# this was made as root and passed while the build kept failing.
# --rbind brings the host's /dev/shm along as it is: already a tmpfs, already
# drwxrwxrwt. Nothing to create, nothing to chmod, nothing of the host's to
# touch. --make-rslave then stops any mount this chroot makes from
# propagating back out.
mountpoint -q "$ROOT/dev" || {
sudo mount --rbind /dev "$ROOT/dev"
sudo mount --make-rslave "$ROOT/dev"
}
# PROVED from inside, as the build user. Every earlier check of this was made
# as root and passed while the build kept failing.
if ! sudo chroot --userspec="$BUILD_UID:$BUILD_GID" "$ROOT" \
/usr/bin/env -i PATH=/usr/bin sh -c \
'f=/dev/shm/.el-probe.$$; : > "$f" && rm -f "$f"' 2>/dev/null; then
printf ' WARNING: /dev/shm is not writable by the build user;\n'
printf ' python will build without POSIX semaphores\n'
'f=/dev/shm/.el-probe.$$; : > "$f" && rm -f "$f"'; then
die "/dev/shm is not writable by the build user; python would build without POSIX semaphores"
fi
# Sources and PKGBUILDs, already fetched by stage 1. Bind-mounting them
# means the chroot needs no network at all, which is worth having: this
@ -591,7 +579,9 @@ mount_chroot() {
}
umount_chroot() {
for m in repo2 build dev/shm dev/pts dev sys proc; do
# -R for dev, because --rbind brought its submounts with it.
mountpoint -q "$ROOT/dev" && sudo umount -R -l "$ROOT/dev"
for m in repo2 build sys proc; do
mountpoint -q "$ROOT/$m" && sudo umount -l "$ROOT/$m"
done
return 0