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