Files
omarchycn/test/shell.d/windows-vm-mount-boundary-test.sh
T

169 lines
8.6 KiB
Bash

#!/bin/bash
# Exercise the real EUID-0/PKEXEC_UID boundary in an isolated user+mount namespace.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
if ((EUID != 0)); then
if unshare --user --map-auto --map-root-user --mount true 2>/dev/null; then
exec unshare --user --map-auto --map-root-user --mount --propagation private bash "$0"
fi
pass "automatic subordinate-id namespace unavailable; skipping root Windows VM boundary probe"
exit 0
fi
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
# Hide host state before creating the production paths used by the root helper.
mount -t tmpfs -o mode=0755,size=8m run-test /run
mkdir -p /run/lock
mount -t tmpfs -o mode=0755,size=16m var-test /var
mkdir -p /var/lib/omarchy
mount -t tmpfs -o mode=0755,size=16m home-parent /home
mkdir /home/alice
mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice
export HOME=/home/alice
unset OMARCHY_WINDOWS_DIR
set -- help
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
# The namespace maps the host filesystem's uid 0 to nobody. Only / remains on
# that filesystem; all paths the helper mutates are isolated tmpfs mounts.
stat() {
if [[ ${!#} == / && $* == *"%u"* ]]; then printf '0\n'; return; fi
command stat "$@"
}
TEST_PASSWD_HOME=/home/alice
getent() {
if [[ $1 == passwd && ${2:-} == 1000 ]]; then
printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME"
return 0
fi
return 2
}
assert_no_runtime_mutation() {
[[ ! -e /var/lib/omarchy/windows && ! -L /var/lib/omarchy/windows ]] ||
fail "$1 mutated the production runtime"
}
unset PKEXEC_UID
resolve_caller 2>/dev/null && fail "root accepted missing PKEXEC_UID"
assert_no_runtime_mutation "missing PKEXEC_UID"
PKEXEC_UID=0
resolve_caller 2>/dev/null && fail "root accepted PKEXEC_UID=0"
assert_no_runtime_mutation "zero PKEXEC_UID"
PKEXEC_UID=not-a-number
resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID"
assert_no_runtime_mutation "nonnumeric PKEXEC_UID"
PKEXEC_UID=1001
resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd"
assert_no_runtime_mutation "missing passwd entry"
PKEXEC_UID=1000
resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller"
assert_no_runtime_mutation "wrong-owned home"
chown 1000:1000 /home/alice
chmod 0777 /home
resolve_caller 2>/dev/null && fail "root accepted writable home parent"
assert_no_runtime_mutation "writable parent"
chmod 0755 /home
mkdir /home/real-alice
chown 1000:1000 /home/real-alice
ln -s /home/real-alice /home/link-alice
TEST_PASSWD_HOME=/home/link-alice
resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home"
assert_no_runtime_mutation "symlinked home"
TEST_PASSWD_HOME=/home/alice
resolve_caller || fail "valid root PKEXEC_UID/home boundary was rejected"
pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and writable-parent boundaries without mutation"
# Put each familiar source on its own filesystem. Both start with legacy 0755
# permissions and world-readable payloads to prove migration hardens the leaves.
mkdir /home/storage-target /home/shared-target
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target
ln -s /home/storage-target /home/alice/.windows
ln -s /home/shared-target /home/alice/Windows
chown -h 1000:1000 /home/alice/.windows /home/alice/Windows
printf disk >/home/storage-target/disk.img
printf shared >/home/shared-target/shared.txt
chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt
chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt
home_dev=$(command stat -Lc '%d' /home/alice)
storage_dev=$(command stat -Lc '%d' /home/storage-target)
[[ $home_dev != "$storage_dev" ]] || fail "storage target did not land on a separate filesystem"
with_vm_lock prepare_caller_mounts || fail "root could not create verified production bind anchors"
resolve_caller
[[ $(readlink /home/alice/.windows) == /home/storage-target &&
$(readlink /home/alice/Windows) == /home/shared-target ]] || fail "root consumed legitimate symlinks"
[[ $(command stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(command stat -Lc '%d:%i' /home/storage-target) ]] || fail "storage bind identity differs from pinned source"
[[ $(command stat -Lc '%d:%i' "$EXPECTED_SHARED") == $(command stat -Lc '%d:%i' /home/shared-target) ]] || fail "shared bind identity differs from pinned source"
[[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem"
[[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 &&
$(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes"
[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 &&
$(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700"
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then
fail "another local account read the VM disk through its anchor"
fi
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then
fail "another local account read shared files through their anchor"
fi
pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account"
expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}')
actual_space=$(available_storage_gb)
[[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem"
[[ $(command df -P -- /home/alice | awk 'NR==2 {print int($4/1024/1024)}') != "$actual_space" ]] || fail "test filesystems do not distinguish home from storage"
pass "disk-space accounting measures the actual storage filesystem, not home"
# Exercise the real root writer and final guard against the production paths.
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=alice\nPASSWORD=pw\nTZ=UTC\n' |
with_vm_lock __priv_write_compose
[[ $(command stat -Lc '%u:%a' "$COMPOSE_FILE") == 0:640 ]] || fail "root compose ownership/mode is wrong"
with_vm_lock assert_mounts_safe || fail "final root mount/compose assertion rejected the verified pair"
pass "root writer and final pre-Docker guard revalidate the pinned production mounts"
# Upgrade the exact sibling-anchor pair emitted by the earlier fix without
# moving or replacing either familiar home symlink.
sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE"
sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE"
compose_needs_mount_migration || fail "previous protected anchor pair was not recognized for upgrade"
with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors"
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor"
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor"
[[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link"
pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary"
# Preflight both sources before either bind on a clean anchor pair.
umount "$EXPECTED_SHARED"
umount "$EXPECTED_STORAGE"
rm /home/alice/Windows
ln -s / /home/alice/Windows
chown -h 1000:1000 /home/alice/Windows
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source"
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind"
[[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink"
pass "root preflights both sources before mounting either and preserves rejection evidence"
# mountpoint(1) follows symlinks, so explicitly pin the invariant that even a
# root-planted anchor symlink to the expected mounted source is rejected.
rm /home/alice/Windows
ln -s /home/shared-target /home/alice/Windows
chown -h 1000:1000 /home/alice/Windows
rmdir "$EXPECTED_STORAGE"
ln -s /home/storage-target "$EXPECTED_STORAGE"
storage_id=$(command stat -Lc '%d:%i' /home/storage-target)
mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && fail "symlink mount anchor passed final identity check"
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root followed a symlink mount anchor"
[[ -L $EXPECTED_STORAGE ]] || fail "rejected anchor symlink was consumed"
pass "final guard rejects a symlink even when it resolves to the expected mounted source"