maint: post-release administrivia
[coreutils.git] / tests / rm / r-root.sh
blob1a4ab40bf493a46c4d3932e3fa0e05fbc50a1172
1 #!/bin/sh
2 # Try to remove '/' recursively.
4 # Copyright (C) 2013-2024 Free Software Foundation, Inc.
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.
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.
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/>.
19 . "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
20 print_ver_ rm
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!
28 # Fainthearted: skip this test for the 'root' user.
29 skip_if_root_
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.
33 require_gcc_shared_
35 # Ensure this variable is unset as it's
36 # used later in the unlinkat() wrapper.
37 unset CU_TEST_SKIP_EXIT
39 # Set this to 0 if you don't have a working gdb but would
40 # still like to run the test
41 USE_GDB=1
43 if test $USE_GDB = 1; then
44 case $host_triplet in
45 *darwin*) skip_ 'avoiding due to potentially non functioning gdb' ;;
46 *) ;;
47 esac
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
57 # Break on a line rather than a symbol, to cater for inline functions
58 break_src="$abs_top_srcdir/src/remove.c"
59 break_line=$(grep -n ^excise "$break_src") || framework_failure_
60 break_line=$(echo "$break_line" | cut -d: -f1) || framework_failure_
61 break_line="$break_src:$break_line"
64 cat > k.c <<'EOF' || framework_failure_
65 #include <stdio.h>
66 #include <stdlib.h>
67 #include <unistd.h>
69 int unlinkat (int dirfd, const char *pathname, int flags)
71 /* Prove that LD_PRELOAD works: create the evidence file "x". */
72 fclose (fopen ("x", "w"));
74 /* Immediately terminate, unless indicated otherwise. */
75 if (! getenv("CU_TEST_SKIP_EXIT"))
76 _exit (0);
78 /* Pretend success. */
79 return 0;
81 EOF
83 # Then compile/link it:
84 gcc_shared_ k.c k.so \
85 || framework_failure_ 'failed to build shared library'
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
90 cat > bp.py <<'EOF.py' || framework_failure_
91 def 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')
104 gdb.events.stop.connect(breakpoint_handler)
105 EOF.py
107 # In order of the sed expressions below, this cleans:
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.
114 clean_rm_err_()
116 sed "s/.*rm: /rm: /; \
117 s/\(rm: it is dangerous to operate recursively on\).*$/\1 '\/'/"
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.
132 exercise_rm_r_root ()
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_
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'
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
160 ret=$?
162 clean_rm_err_ < err.t > err || ret=$?
164 return $ret
167 # Verify that "rm -r dir" basically works.
168 mkdir dir || framework_failure_
169 rm -r dir || framework_failure_
170 test -d dir && framework_failure_
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.
175 mkdir dir || framework_failure_
176 > file || framework_failure_
178 skip=
179 for 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
186 test "$skip" = 1 \
187 && { cat out; cat err; \
188 skip_ "internal test failure: maybe LD_PRELOAD or gdb doesn't work?"; }
189 done
191 # "rm -r /" without --no-preserve-root should output the following
192 # diagnostic error message.
193 cat <<EOD > exp || framework_failure_
194 rm: it is dangerous to operate recursively on '/'
195 rm: use --no-preserve-root to override this failsafe
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 "/".
203 ln -s / rootlink || framework_failure_
204 ln -s rootlink rootlink2 || framework_failure_
205 ln -sr / rootlink3 || framework_failure_
207 for opts in \
208 '/' \
209 '--preserve-root /' \
210 '//' \
211 '///' \
212 '////' \
213 'rootlink/' \
214 'rootlink2/' \
215 'rootlink3/' ; do
217 returns_ 1 exercise_rm_r_root $opts || fail=1
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
226 # Do nothing more if this test failed.
227 test $fail = 1 && { cat out; cat err; Exit $fail; }
228 done
230 #-------------------------------------------------------------------------------
231 # Exercise with --no-preserve to ensure shortened equivalent is not allowed.
232 cat <<EOD > exp_opt || framework_failure_
233 rm: you may not abbreviate the --no-preserve-root option
235 returns_ 1 exercise_rm_r_root --no-preserve / || fail=1
236 compare exp_opt err || fail=1
237 test -f x && fail=1
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_
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.
251 CU_TEST_SKIP_EXIT=1
253 returns_ 1 exercise_rm_r_root --preserve-root file1 '/' file2 || fail=1
255 unset CU_TEST_SKIP_EXIT
257 cat <<EOD > out_removed
258 removed 'file1'
259 removed 'file2'
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.
266 compare out_removed out || fail=1
267 compare exp err || fail=1
268 test -f x || fail=1
270 # Do nothing more if this test failed.
271 test $fail = 1 && { cat out; cat err; Exit $fail; }
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.
280 for file in \
281 '//.' \
282 '/./' \
283 '/.//' \
284 '/../' \
285 '/.././' \
286 '/etc/..' \
287 'rootlink/..' \
288 'rootlink2/.' \
289 'rootlink3/./' ; do
291 test -d "$file" || continue # if e.g. /etc does not exist.
293 returns_ 1 exercise_rm_r_root --preserve-root "$file" || fail=1
295 grep "rm: refusing to remove '\.' or '\.\.' directory: skipping" err \
296 || fail=1
298 compare /dev/null out || fail=1
299 test -f x && fail=1
301 # Do nothing more if this test failed.
302 test $fail = 1 && { cat out; cat err; Exit $fail; }
303 done
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 '/'?"
313 exercise_rm_r_root --interactive=never --no-preserve-root '/' \
314 || fail=1
316 # The 'err' file should not contain the above error diagnostic.
317 grep "rm: it is dangerous to operate recursively on '/'" err && fail=1
319 # Instead, rm(1) should have called the intercepted unlinkat() function,
320 # i.e., the evidence file "x" should exist.
321 test -f x || fail=1
323 test $fail = 1 && { cat out; cat err; }
325 Exit $fail