1#!/bin/sh 2# Try to remove '/' recursively. 3 4# Copyright (C) 2013-2023 Free Software Foundation, Inc. 5 6# This program is free software: you can redistribute it and/or modify 7# it under the terms of the GNU General Public License as published by 8# the Free Software Foundation, either version 3 of the License, or 9# (at your option) any later version. 10 11# This program is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14# GNU General Public License for more details. 15 16# You should have received a copy of the GNU General Public License 17# along with this program. If not, see <https://www.gnu.org/licenses/>. 18 19. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src 20print_ver_ rm 21 22# POSIX mandates rm(1) to skip '/' arguments. This test verifies this mandated 23# behavior as well as the --preserve-root and --no-preserve-root options. 24# Especially the latter case is a live fire exercise as rm(1) is supposed to 25# enter the unlinkat() system call. Therefore, limit the risk as much 26# as possible -- if there's a bug this test would wipe the system out! 27 28# Fainthearted: skip this test for the 'root' user. 29skip_if_root_ 30 31# Pull the teeth from rm(1) by intercepting the unlinkat() system call via the 32# LD_PRELOAD environment variable. This requires shared libraries to work. 33require_gcc_shared_ 34 35# Ensure this variable is unset as it's 36# used later in the unlinkat() wrapper. 37unset CU_TEST_SKIP_EXIT 38 39# Set this to 0 if you don't have a working gdb but would 40# still like to run the test 41USE_GDB=1 42 43if test $USE_GDB = 1; then 44 case $host_triplet in 45 *darwin*) skip_ 'avoiding due to potentially non functioning gdb' ;; 46 *) ;; 47 esac 48 49 # Use gdb to provide further protection by limiting calls to unlinkat(). 50 ( timeout 10s gdb --version ) > gdb.out 2>&1 51 case $(cat gdb.out) in 52 *'GNU gdb'*) ;; 53 *) skip_ "can't run gdb";; 54 esac 55fi 56 57# Break on a line rather than a symbol, to cater for inline functions 58break_src="$abs_top_srcdir/src/remove.c" 59break_line=$(grep -n ^excise "$break_src") || framework_failure_ 60break_line=$(echo "$break_line" | cut -d: -f1) || framework_failure_ 61break_line="$break_src:$break_line" 62 63 64cat > k.c <<'EOF' || framework_failure_ 65#include <stdio.h> 66#include <stdlib.h> 67#include <unistd.h> 68 69int unlinkat (int dirfd, const char *pathname, int flags) 70{ 71 /* Prove that LD_PRELOAD works: create the evidence file "x". */ 72 fclose (fopen ("x", "w")); 73 74 /* Immediately terminate, unless indicated otherwise. */ 75 if (! getenv("CU_TEST_SKIP_EXIT")) 76 _exit (0); 77 78 /* Pretend success. */ 79 return 0; 80} 81EOF 82 83# Then compile/link it: 84gcc_shared_ k.c k.so \ 85 || framework_failure_ 'failed to build shared library' 86 87# Note breakpoint commands don't work in batch mode 88# https://sourceware.org/bugzilla/show_bug.cgi?id=10079 89# So we use python to script behavior upon hitting the breakpoint 90cat > bp.py <<'EOF.py' || framework_failure_ 91def breakpoint_handler (event): 92 if not isinstance(event, gdb.BreakpointEvent): 93 return 94 hit_count = event.breakpoints[0].hit_count 95 if hit_count == 1: 96 gdb.execute('shell touch excise.break') 97 gdb.execute('continue') 98 elif hit_count > 2: 99 gdb.write('breakpoint hit twice already') 100 gdb.execute('quit 1') 101 else: 102 gdb.execute('continue') 103 104gdb.events.stop.connect(breakpoint_handler) 105EOF.py 106 107# In order of the sed expressions below, this cleans: 108# 109# 1. gdb uses the full path when running rm, so remove the leading dirs. 110# 2. For some of the "/" synonyms, the error diagnostic slightly differs from 111# that of the basic "/" case (see gnulib's fts_open' and ROOT_DEV_INO_WARN): 112# rm: it is dangerous to operate recursively on 'FILE' (same as '/') 113# Strip that part off for the following comparison. 114clean_rm_err_() 115{ 116 sed "s/.*rm: /rm: /; \ 117 s/\(rm: it is dangerous to operate recursively on\).*$/\1 '\/'/" 118} 119 120#------------------------------------------------------------------------------- 121# exercise_rm_r_root: shell function to test "rm -r '/'" 122# The caller must provide the FILE to remove as well as any options 123# which should be passed to 'rm'. 124# Paranoia mode on: 125# For the worst case where both rm(1) would fail to refuse to process the "/" 126# argument (in the cases without the --no-preserve-root option), and 127# intercepting the unlinkat(1) system call would fail (which actually already 128# has been proven to work above), and the current non root user has 129# write access to "/", limit the damage to the current file system via 130# the --one-file-system option. 131# Furthermore, run rm(1) via gdb that limits the number of unlinkat() calls. 132exercise_rm_r_root () 133{ 134 # Remove the evidence files; verify that. 135 rm -f x excise.break || framework_failure_ 136 test -f x && framework_failure_ 137 test -f excise.break && framework_failure_ 138 139 local skip_exit= 140 if [ "$CU_TEST_SKIP_EXIT" = 1 ]; then 141 # Pass on this variable into 'rm's environment. 142 skip_exit='CU_TEST_SKIP_EXIT=1' 143 fi 144 145 if test $USE_GDB = 1; then 146 gdb -nx --batch-silent -return-child-result \ 147 --eval-command="set exec-wrapper \ 148 env 'LD_PRELOAD=$LD_PRELOAD:./k.so' $skip_exit" \ 149 --eval-command="break '$break_line'" \ 150 --eval-command='source bp.py' \ 151 --eval-command="run -rv --one-file-system $*" \ 152 --eval-command='quit' \ 153 rm < /dev/null > out 2> err.t 154 else 155 touch excise.break 156 env LD_PRELOAD=$LD_PRELOAD:./k.so $skip_exit \ 157 rm -rv --one-file-system $* < /dev/null > out 2> err.t 158 fi 159 160 ret=$? 161 162 clean_rm_err_ < err.t > err || ret=$? 163 164 return $ret 165} 166 167# Verify that "rm -r dir" basically works. 168mkdir dir || framework_failure_ 169rm -r dir || framework_failure_ 170test -d dir && framework_failure_ 171 172# Now verify that intercepting unlinkat() works: 173# rm(1) must succeed as before, but this time both the evidence file "x" 174# and the test file / directory must still exist afterward. 175mkdir dir || framework_failure_ 176> file || framework_failure_ 177 178skip= 179for file in dir file ; do 180 exercise_rm_r_root "$file" || skip=1 181 test -e "$file" || skip=1 182 test -f x || skip=1 183 test -f excise.break || skip=1 # gdb works and breakpoint hit 184 compare /dev/null err || skip=1 185 186 test "$skip" = 1 \ 187 && { cat out; cat err; \ 188 skip_ "internal test failure: maybe LD_PRELOAD or gdb doesn't work?"; } 189done 190 191# "rm -r /" without --no-preserve-root should output the following 192# diagnostic error message. 193cat <<EOD > exp || framework_failure_ 194rm: it is dangerous to operate recursively on '/' 195rm: use --no-preserve-root to override this failsafe 196EOD 197 198#------------------------------------------------------------------------------- 199# Exercise "rm -r /" without and with the --preserve-root option. 200# Exercise various synonyms of "/" including symlinks to it. 201# Expect a non-Zero exit status. 202# Prepare a few symlinks to "/". 203ln -s / rootlink || framework_failure_ 204ln -s rootlink rootlink2 || framework_failure_ 205ln -sr / rootlink3 || framework_failure_ 206 207for opts in \ 208 '/' \ 209 '--preserve-root /' \ 210 '//' \ 211 '///' \ 212 '////' \ 213 'rootlink/' \ 214 'rootlink2/' \ 215 'rootlink3/' ; do 216 217 returns_ 1 exercise_rm_r_root $opts || fail=1 218 219 # Expect nothing in 'out' and the above error diagnostic in 'err'. 220 # As rm(1) should have skipped the "/" argument, it does not call unlinkat(). 221 # Therefore, the evidence file "x" should not exist. 222 compare /dev/null out || fail=1 223 compare exp err || fail=1 224 test -f x && fail=1 225 226 # Do nothing more if this test failed. 227 test $fail = 1 && { cat out; cat err; Exit $fail; } 228done 229 230#------------------------------------------------------------------------------- 231# Exercise with --no-preserve to ensure shortened equivalent is not allowed. 232cat <<EOD > exp_opt || framework_failure_ 233rm: you may not abbreviate the --no-preserve-root option 234EOD 235returns_ 1 exercise_rm_r_root --no-preserve / || fail=1 236compare exp_opt err || fail=1 237test -f x && fail=1 238 239#------------------------------------------------------------------------------- 240# Exercise "rm -r file1 / file2". 241# Expect a non-Zero exit status representing failure to remove "/", 242# yet 'file1' and 'file2' should be removed. 243> file1 || framework_failure_ 244> file2 || framework_failure_ 245 246# Now that we know that 'rm' won't call the unlinkat() system function for "/", 247# we could probably execute it without the LD_PRELOAD'ed safety net. 248# Nevertheless, it's still better to use it for this test. 249# Tell the unlinkat() replacement function to not _exit(0) immediately 250# by setting the following variable. 251CU_TEST_SKIP_EXIT=1 252 253returns_ 1 exercise_rm_r_root --preserve-root file1 '/' file2 || fail=1 254 255unset CU_TEST_SKIP_EXIT 256 257cat <<EOD > out_removed 258removed 'file1' 259removed 'file2' 260EOD 261 262# The above error diagnostic should appear in 'err'. 263# Both 'file1' and 'file2' should be removed. Simply verify that in the 264# "out" file, as the replacement unlinkat() dummy did not remove them. 265# Expect the evidence file "x" to exist. 266compare out_removed out || fail=1 267compare exp err || fail=1 268test -f x || fail=1 269 270# Do nothing more if this test failed. 271test $fail = 1 && { cat out; cat err; Exit $fail; } 272 273#------------------------------------------------------------------------------- 274# Exercise various synonyms of "/" having a trailing "." or ".." in the name. 275# This triggers another check in the code first and therefore leads to a 276# different diagnostic. However, we want to test anyway to protect against 277# future reordering of the checks in the code. 278# Expect that other error diagnostic in 'err' and nothing in 'out'. 279# Expect a non-Zero exit status. The evidence file "x" should not exist. 280for file in \ 281 '//.' \ 282 '/./' \ 283 '/.//' \ 284 '/../' \ 285 '/.././' \ 286 '/etc/..' \ 287 'rootlink/..' \ 288 'rootlink2/.' \ 289 'rootlink3/./' ; do 290 291 test -d "$file" || continue # if e.g. /etc does not exist. 292 293 returns_ 1 exercise_rm_r_root --preserve-root "$file" || fail=1 294 295 grep "rm: refusing to remove '\.' or '\.\.' directory: skipping" err \ 296 || fail=1 297 298 compare /dev/null out || fail=1 299 test -f x && fail=1 300 301 # Do nothing more if this test failed. 302 test $fail = 1 && { cat out; cat err; Exit $fail; } 303done 304 305#------------------------------------------------------------------------------- 306# Until now, it was all just fun. 307# Now exercise the --no-preserve-root option with which rm(1) should enter 308# the intercepted unlinkat() system call. 309# As the interception code terminates the process immediately via _exit(0), 310# the exit status should be 0. 311# Use the option --interactive=never to bypass the following prompt: 312# "rm: descend into write-protected directory '/'?" 313exercise_rm_r_root --interactive=never --no-preserve-root '/' \ 314 || fail=1 315 316# The 'err' file should not contain the above error diagnostic. 317grep "rm: it is dangerous to operate recursively on '/'" err && fail=1 318 319# Instead, rm(1) should have called the intercepted unlinkat() function, 320# i.e., the evidence file "x" should exist. 321test -f x || fail=1 322 323test $fail = 1 && { cat out; cat err; } 324 325Exit $fail 326