summaryrefslogtreecommitdiffstats
path: root/tests/rm/r-root.sh
blob: d60c5e7fde7e23af0a26044357922233149ecb12 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
#!/bin/sh
# Try to remove '/' recursively.

# Copyright (C) 2013-2023 Free Software Foundation, Inc.

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
print_ver_ rm

# POSIX mandates rm(1) to skip '/' arguments.  This test verifies this mandated
# behavior as well as the --preserve-root and --no-preserve-root options.
# Especially the latter case is a live fire exercise as rm(1) is supposed to
# enter the unlinkat() system call.  Therefore, limit the risk as much
# as possible -- if there's a bug this test would wipe the system out!

# Fainthearted: skip this test for the 'root' user.
skip_if_root_

# Pull the teeth from rm(1) by intercepting the unlinkat() system call via the
# LD_PRELOAD environment variable.  This requires shared libraries to work.
require_gcc_shared_

# Ensure this variable is unset as it's
# used later in the unlinkat() wrapper.
unset CU_TEST_SKIP_EXIT

# Set this to 0 if you don't have a working gdb but would
# still like to run the test
USE_GDB=1

if test $USE_GDB = 1; then
  case $host_triplet in
    *darwin*) skip_ 'avoiding due to potentially non functioning gdb' ;;
    *) ;;
  esac

  # Use gdb to provide further protection by limiting calls to unlinkat().
  ( timeout 10s gdb --version ) > gdb.out 2>&1
  case $(cat gdb.out) in
    *'GNU gdb'*) ;;
    *) skip_ "can't run gdb";;
  esac
fi

# Break on a line rather than a symbol, to cater for inline functions
break_src="$abs_top_srcdir/src/remove.c"
break_line=$(grep -n ^excise "$break_src") || framework_failure_
break_line=$(echo "$break_line" | cut -d: -f1) || framework_failure_
break_line="$break_src:$break_line"


cat > k.c <<'EOF' || framework_failure_
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int unlinkat (int dirfd, const char *pathname, int flags)
{
  /* Prove that LD_PRELOAD works: create the evidence file "x".  */
  fclose (fopen ("x", "w"));

  /* Immediately terminate, unless indicated otherwise.  */
  if (! getenv("CU_TEST_SKIP_EXIT"))
    _exit (0);

  /* Pretend success.  */
  return 0;
}
EOF

# Then compile/link it:
gcc_shared_ k.c k.so \
  || framework_failure_ 'failed to build shared library'

# Note breakpoint commands don't work in batch mode
# https://sourceware.org/bugzilla/show_bug.cgi?id=10079
# So we use python to script behavior upon hitting the breakpoint
cat > bp.py <<'EOF.py' || framework_failure_
def breakpoint_handler (event):
  if not isinstance(event, gdb.BreakpointEvent):
    return
  hit_count = event.breakpoints[0].hit_count
  if hit_count == 1:
    gdb.execute('shell touch excise.break')
    gdb.execute('continue')
  elif hit_count > 2:
    gdb.write('breakpoint hit twice already')
    gdb.execute('quit 1')
  else:
    gdb.execute('continue')

gdb.events.stop.connect(breakpoint_handler)
EOF.py

# In order of the sed expressions below, this cleans:
#
# 1. gdb uses the full path when running rm, so remove the leading dirs.
# 2. For some of the "/" synonyms, the error diagnostic slightly differs from
# that of the basic "/" case (see gnulib's fts_open' and ROOT_DEV_INO_WARN):
#   rm: it is dangerous to operate recursively on 'FILE' (same as '/')
# Strip that part off for the following comparison.
clean_rm_err_()
{
  sed "s/.*rm: /rm: /; \
       s/\(rm: it is dangerous to operate recursively on\).*$/\1 '\/'/"
}

#-------------------------------------------------------------------------------
# exercise_rm_r_root: shell function to test "rm -r '/'"
# The caller must provide the FILE to remove as well as any options
# which should be passed to 'rm'.
# Paranoia mode on:
# For the worst case where both rm(1) would fail to refuse to process the "/"
# argument (in the cases without the --no-preserve-root option), and
# intercepting the unlinkat(1) system call would fail (which actually already
# has been proven to work above), and the current non root user has
# write access to "/", limit the damage to the current file system via
# the --one-file-system option.
# Furthermore, run rm(1) via gdb that limits the number of unlinkat() calls.
exercise_rm_r_root ()
{
  # Remove the evidence files; verify that.
  rm -f x excise.break || framework_failure_
  test -f x && framework_failure_
  test -f excise.break && framework_failure_

  local skip_exit=
  if [ "$CU_TEST_SKIP_EXIT" = 1 ]; then
    # Pass on this variable into 'rm's environment.
    skip_exit='CU_TEST_SKIP_EXIT=1'
  fi

  if test $USE_GDB = 1; then
    gdb -nx --batch-silent -return-child-result				\
      --eval-command="set exec-wrapper					\
                       env 'LD_PRELOAD=$LD_PRELOAD:./k.so' $skip_exit"	\
      --eval-command="break '$break_line'"				\
      --eval-command='source bp.py'					\
      --eval-command="run -rv --one-file-system $*"			\
      --eval-command='quit'						\
      rm < /dev/null > out 2> err.t
  else
    touch excise.break
    env LD_PRELOAD=$LD_PRELOAD:./k.so $skip_exit \
      rm -rv --one-file-system $* < /dev/null > out 2> err.t
  fi

  ret=$?

  clean_rm_err_ < err.t > err || ret=$?

  return $ret
}

# Verify that "rm -r dir" basically works.
mkdir   dir || framework_failure_
rm -r   dir || framework_failure_
test -d dir && framework_failure_

# Now verify that intercepting unlinkat() works:
# rm(1) must succeed as before, but this time both the evidence file "x"
# and the test file / directory must still exist afterward.
mkdir dir || framework_failure_
> file    || framework_failure_

skip=
for file in dir file ; do
  exercise_rm_r_root "$file" || skip=1
  test -e "$file"            || skip=1
  test -f x                  || skip=1
  test -f excise.break       || skip=1  # gdb works and breakpoint hit
  compare /dev/null err      || skip=1

  test "$skip" = 1 \
    && { cat out; cat err; \
         skip_ "internal test failure: maybe LD_PRELOAD or gdb doesn't work?"; }
done

# "rm -r /" without --no-preserve-root should output the following
# diagnostic error message.
cat <<EOD > exp || framework_failure_
rm: it is dangerous to operate recursively on '/'
rm: use --no-preserve-root to override this failsafe
EOD

#-------------------------------------------------------------------------------
# Exercise "rm -r /" without and with the --preserve-root option.
# Exercise various synonyms of "/" including symlinks to it.
# Expect a non-Zero exit status.
# Prepare a few symlinks to "/".
ln -s /        rootlink  || framework_failure_
ln -s rootlink rootlink2 || framework_failure_
ln -sr /       rootlink3 || framework_failure_

for opts in           \
  '/'                 \
  '--preserve-root /' \
  '//'                \
  '///'               \
  '////'              \
  'rootlink/'         \
  'rootlink2/'        \
  'rootlink3/'        ; do

  returns_ 1 exercise_rm_r_root $opts || fail=1

  # Expect nothing in 'out' and the above error diagnostic in 'err'.
  # As rm(1) should have skipped the "/" argument, it does not call unlinkat().
  # Therefore, the evidence file "x" should not exist.
  compare /dev/null out || fail=1
  compare exp       err || fail=1
  test -f x             && fail=1

  # Do nothing more if this test failed.
  test $fail = 1 && { cat out; cat err; Exit $fail; }
done

#-------------------------------------------------------------------------------
# Exercise with --no-preserve to ensure shortened equivalent is not allowed.
cat <<EOD > exp_opt || framework_failure_
rm: you may not abbreviate the --no-preserve-root option
EOD
returns_ 1 exercise_rm_r_root --no-preserve / || fail=1
compare exp_opt err || fail=1
test -f x && fail=1

#-------------------------------------------------------------------------------
# Exercise "rm -r file1 / file2".
# Expect a non-Zero exit status representing failure to remove "/",
# yet 'file1' and 'file2' should be removed.
> file1 || framework_failure_
> file2 || framework_failure_

# Now that we know that 'rm' won't call the unlinkat() system function for "/",
# we could probably execute it without the LD_PRELOAD'ed safety net.
# Nevertheless, it's still better to use it for this test.
# Tell the unlinkat() replacement function to not _exit(0) immediately
# by setting the following variable.
CU_TEST_SKIP_EXIT=1

returns_ 1 exercise_rm_r_root --preserve-root file1 '/' file2 || fail=1

unset CU_TEST_SKIP_EXIT

cat <<EOD > out_removed
removed 'file1'
removed 'file2'
EOD

# The above error diagnostic should appear in 'err'.
# Both 'file1' and 'file2' should be removed.  Simply verify that in the
# "out" file, as the replacement unlinkat() dummy did not remove them.
# Expect the evidence file "x" to exist.
compare out_removed out || fail=1
compare exp         err || fail=1
test -f x               || fail=1

# Do nothing more if this test failed.
test $fail = 1 && { cat out; cat err; Exit $fail; }

#-------------------------------------------------------------------------------
# Exercise various synonyms of "/" having a trailing "." or ".." in the name.
# This triggers another check in the code first and therefore leads to a
# different diagnostic.  However, we want to test anyway to protect against
# future reordering of the checks in the code.
# Expect that other error diagnostic in 'err' and nothing in 'out'.
# Expect a non-Zero exit status.  The evidence file "x" should not exist.
for file in      \
  '//.'          \
  '/./'          \
  '/.//'         \
  '/../'         \
  '/.././'       \
  '/etc/..'      \
  'rootlink/..'  \
  'rootlink2/.'  \
  'rootlink3/./' ; do

  test -d "$file" || continue   # if e.g. /etc does not exist.

  returns_ 1 exercise_rm_r_root --preserve-root "$file" || fail=1

  grep "rm: refusing to remove '\.' or '\.\.' directory: skipping" err \
    || fail=1

  compare /dev/null out  || fail=1
  test -f x              && fail=1

  # Do nothing more if this test failed.
  test $fail = 1 && { cat out; cat err; Exit $fail; }
done

#-------------------------------------------------------------------------------
# Until now, it was all just fun.
# Now exercise the --no-preserve-root option with which rm(1) should enter
# the intercepted unlinkat() system call.
# As the interception code terminates the process immediately via _exit(0),
# the exit status should be 0.
# Use the option --interactive=never to bypass the following prompt:
#   "rm: descend into write-protected directory '/'?"
exercise_rm_r_root  --interactive=never --no-preserve-root '/' \
  || fail=1

# The 'err' file should not contain the above error diagnostic.
grep "rm: it is dangerous to operate recursively on '/'" err && fail=1

# Instead, rm(1) should have called the intercepted unlinkat() function,
# i.e., the evidence file "x" should exist.
test -f x || fail=1

test $fail = 1 && { cat out; cat err; }

Exit $fail