Cover the ownership and mode checks the publisher rests on

Mutating each root-side control one at a time showed three that no test could
see: a destination directory root does not own, a single user-owned asset
inside an otherwise root-owned packaged directory, and an asset left group- or
world-writable by its own mode. Deleting any of the three left the suite green,
because the existing cases mark a whole tree untrusted and are caught by the
directory check before the per-file one is reached.

The harness already had the hook for the ownership pair: TEST_UNTRUSTED_SOURCE
makes the stat shim report a chosen prefix as uid 1000, so those two only need
it pointed at a destination directory and at a single file rather than at a
whole tree. A mode has to be real, so that case stages a copy of the packaged
tree the shim reports as root-owned and loosens one asset in it.

The empty logo is refused by the destination size bound rather than the
caller-side one, so that case pins the behaviour without isolating the check;
the two bounds are exactly redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki
This commit is contained in:
David Heinemeier Hansson
2026-08-29 17:39:33 +02:00
co-authored by Claude Opus 5
parent 363db1f569
commit d67a7c00bc
+63
View File
@@ -531,6 +531,69 @@ assert_no_temporary_files "$fake_root"
pass "publication walks the whole parent chain, not only the immediate parent"
# Mode is not the only thing that decides a destination directory. One that is
# merely user-owned still lets its owner put the file back after we publish, so
# ownership has to refuse it even when 0755 looks harmless.
setup_run
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$theme" 2>&1)
status=$?
(( status != 0 )) || fail "a user-owned destination directory is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned destination directory keeps its live file"
[[ $output == *"refusing to publish"* ]] || fail "a rejected destination directory says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "publication refuses a destination directory root does not own"
# The packaged tree is validated file by file, not only directory by directory.
# A single user-owned asset inside an otherwise root-owned directory is still
# content a desktop process can rewrite, and the directory check cannot see it.
setup_run
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth/bullet.png" 2>&1)
status=$?
(( status != 0 )) || fail "a single user-owned packaged asset is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "one untrusted asset leaves the live theme unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged asset says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root checks every packaged asset, not only its directory"
# Ownership is not the only way a packaged asset stays rewritable: a group- or
# world-writable mode does it too. Stage a tree the shim reports as root-owned
# so only the real mode can decide, then loosen one asset.
setup_run
writable_root=$(mktemp -d "$test_tmp/writable-source.XXXXXXXX")
writable_root=$(realpath -e -- "$writable_root")
mkdir -p "$writable_root/default"
cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_root/default/"
chmod 0666 "$writable_root/default/plymouth/bullet.png"
output=$(run_set 022 env OMARCHY_PATH="$writable_root" 2>&1)
status=$?
(( status != 0 )) || fail "a world-writable packaged asset is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged asset leaves the live theme unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected writable asset says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root refuses a packaged asset its own mode leaves rewritable"
# The caller streams the logo to root over a descriptor, so root is the only
# place its length can be judged. An empty selection must not become an empty
# published logo.
setup_run
cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep"
: >"$test_tmp/logo.png"
output=$(run_set 022 env 2>&1)
status=$?
mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png"
(( status != 0 )) || fail "an empty logo is rejected" "$output"
[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an empty logo leaves the live logo unchanged"
assert_no_temporary_files "$fake_root"
pass "an empty logo cannot be published"
# Refresh uses the same publisher but its explicit contract includes the
# packaged nested logos/oma.png asset. It must not touch the SDDM theme.
setup_run