safe-rm 3.1.5 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/nodejs.yml +1 -0
- package/README.md +45 -0
- package/bin/rm.sh +127 -3
- package/package.json +1 -1
- package/test/cases.js +217 -0
package/README.md
CHANGED
|
@@ -161,6 +161,51 @@ export SAFE_RM_TRASH=/path/to/trash
|
|
|
161
161
|
export SAFE_RM_PERM_DEL_FILES_IN_TRASH=yes
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
### Restrict Removals To A Safe Directory Scope
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
export SAFE_RM_SCOPE="$HOME"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
When `SAFE_RM_SCOPE` is set, `safe-rm` only removes targets under that directory.
|
|
171
|
+
Targets outside the configured scope will be skipped and reported as unsafe.
|
|
172
|
+
|
|
173
|
+
For example:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
SAFE_RM_SCOPE="$HOME" safe-rm -rf /
|
|
177
|
+
# rm: target '/' skipped, unsafe directory scope
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
You could also use a relative scope such as `.`:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
SAFE_RM_SCOPE=. safe-rm -rf ./foo
|
|
184
|
+
SAFE_RM_SCOPE=. safe-rm -rf ../
|
|
185
|
+
# rm: target '../' skipped, unsafe directory scope
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Honor Options After Operands (`rm dir -rf`)
|
|
189
|
+
|
|
190
|
+
GNU `rm` (most Linux distros) accepts options anywhere on the command line, so `rm dir -rf` works like `rm -rf dir`. BSD `rm` (MacOS) does not — it stops parsing options at the first operand and would treat `-rf` as a filename.
|
|
191
|
+
|
|
192
|
+
By default, `safe-rm` mirrors the host's real `rm`:
|
|
193
|
+
|
|
194
|
+
- On Linux: options after operands are parsed as flags (GNU style).
|
|
195
|
+
- On MacOS: options after operands are treated as filenames (BSD style).
|
|
196
|
+
|
|
197
|
+
To override the auto-detection (e.g. on Alpine/BusyBox where `rm` does not permute):
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
# Force GNU-style permutation
|
|
201
|
+
export SAFE_RM_OPTIONS_ANYWHERE=yes
|
|
202
|
+
|
|
203
|
+
# Force BSD-style strict ordering
|
|
204
|
+
export SAFE_RM_OPTIONS_ANYWHERE=no
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`--` always terminates option parsing in either mode.
|
|
208
|
+
|
|
164
209
|
### Protect Files And Directories From Deleting
|
|
165
210
|
|
|
166
211
|
If you want to protect some certain files or directories from deleting by mistake, you could create a `.gitignore` file under the `"~/.safe-rm/"` directory, you could write [.gitignore rules](https://git-scm.com/docs/gitignore) inside the file.
|
package/bin/rm.sh
CHANGED
|
@@ -20,10 +20,21 @@ fi
|
|
|
20
20
|
# ```
|
|
21
21
|
SAFE_RM_CONFIG=${SAFE_RM_CONFIG:="$SAFE_RM_CONFIG_ROOT/config"}
|
|
22
22
|
|
|
23
|
+
SAFE_RM_SCOPE_ENV_DEFINED=
|
|
24
|
+
SAFE_RM_SCOPE_ENV_VALUE=
|
|
25
|
+
if [[ ${SAFE_RM_SCOPE+x} ]]; then
|
|
26
|
+
SAFE_RM_SCOPE_ENV_DEFINED=1
|
|
27
|
+
SAFE_RM_SCOPE_ENV_VALUE=$SAFE_RM_SCOPE
|
|
28
|
+
fi
|
|
29
|
+
|
|
23
30
|
if [[ -f "$SAFE_RM_CONFIG" ]]; then
|
|
24
31
|
source "$SAFE_RM_CONFIG"
|
|
25
32
|
fi
|
|
26
33
|
|
|
34
|
+
if [[ -n "$SAFE_RM_SCOPE_ENV_DEFINED" ]]; then
|
|
35
|
+
SAFE_RM_SCOPE=$SAFE_RM_SCOPE_ENV_VALUE
|
|
36
|
+
fi
|
|
37
|
+
|
|
27
38
|
# Print debug info or not
|
|
28
39
|
SAFE_RM_DEBUG=${SAFE_RM_DEBUG:=}
|
|
29
40
|
|
|
@@ -79,6 +90,25 @@ else
|
|
|
79
90
|
fi
|
|
80
91
|
|
|
81
92
|
|
|
93
|
+
# Whether to honor options after the first operand (`rm dir -rf`).
|
|
94
|
+
# Defaults to auto: enabled on Linux, disabled on MacOS. yes|no to override.
|
|
95
|
+
case ${SAFE_RM_OPTIONS_ANYWHERE:0:1} in
|
|
96
|
+
[yY])
|
|
97
|
+
OPTIONS_ANYWHERE=1
|
|
98
|
+
;;
|
|
99
|
+
[nN])
|
|
100
|
+
OPTIONS_ANYWHERE=
|
|
101
|
+
;;
|
|
102
|
+
*)
|
|
103
|
+
if [[ "$OS_TYPE" == "Linux" ]]; then
|
|
104
|
+
OPTIONS_ANYWHERE=1
|
|
105
|
+
else
|
|
106
|
+
OPTIONS_ANYWHERE=
|
|
107
|
+
fi
|
|
108
|
+
;;
|
|
109
|
+
esac
|
|
110
|
+
|
|
111
|
+
|
|
82
112
|
# The target trash directory to dispose files and directories,
|
|
83
113
|
# defaults to the system trash directory
|
|
84
114
|
SAFE_RM_TRASH=${SAFE_RM_TRASH:="$DEFAULT_TRASH"}
|
|
@@ -259,7 +289,7 @@ while [[ -n $1 ]]; do
|
|
|
259
289
|
# -> args: [], files: ['-']
|
|
260
290
|
*)
|
|
261
291
|
push_file "$1"; debug "$LINENO: file $1"
|
|
262
|
-
ARG_END=1
|
|
292
|
+
[[ -z "$OPTIONS_ANYWHERE" ]] && ARG_END=1
|
|
263
293
|
;;
|
|
264
294
|
esac
|
|
265
295
|
fi
|
|
@@ -351,6 +381,8 @@ check_return_status(){
|
|
|
351
381
|
remove(){
|
|
352
382
|
local file=$1
|
|
353
383
|
|
|
384
|
+
ensure_safe_scope "$file" || return 1
|
|
385
|
+
|
|
354
386
|
# if is dir
|
|
355
387
|
if [[ -d "$file" && ! -L "$file" ]]; then
|
|
356
388
|
|
|
@@ -492,9 +524,56 @@ do_trash(){
|
|
|
492
524
|
get_absolute_path(){
|
|
493
525
|
local dir
|
|
494
526
|
local base
|
|
495
|
-
|
|
527
|
+
if [[ "$1" == "/" ]]; then
|
|
528
|
+
printf '/\n'
|
|
529
|
+
return
|
|
530
|
+
fi
|
|
531
|
+
|
|
532
|
+
dir=$(cd "$(dirname -- "$1")" && pwd) || return 1
|
|
496
533
|
base=$(basename -- "$1")
|
|
497
|
-
|
|
534
|
+
|
|
535
|
+
case "$base" in
|
|
536
|
+
.)
|
|
537
|
+
printf '%s\n' "$dir"
|
|
538
|
+
;;
|
|
539
|
+
|
|
540
|
+
..)
|
|
541
|
+
dir=$(cd "$dir/.." && pwd) || return 1
|
|
542
|
+
printf '%s\n' "$dir"
|
|
543
|
+
;;
|
|
544
|
+
|
|
545
|
+
*)
|
|
546
|
+
printf '%s/%s\n' "$dir" "$base"
|
|
547
|
+
;;
|
|
548
|
+
esac
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
expand_home_path(){
|
|
553
|
+
case $1 in
|
|
554
|
+
"~")
|
|
555
|
+
printf '%s\n' "$HOME"
|
|
556
|
+
;;
|
|
557
|
+
|
|
558
|
+
"~/"*)
|
|
559
|
+
printf '%s/%s\n' "$HOME" "${1#~/}"
|
|
560
|
+
;;
|
|
561
|
+
|
|
562
|
+
*)
|
|
563
|
+
printf '%s\n' "$1"
|
|
564
|
+
;;
|
|
565
|
+
esac
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
resolve_directory_path(){
|
|
570
|
+
local path
|
|
571
|
+
path=$(expand_home_path "$1")
|
|
572
|
+
|
|
573
|
+
(
|
|
574
|
+
cd "$path" &> /dev/null || exit 1
|
|
575
|
+
pwd
|
|
576
|
+
)
|
|
498
577
|
}
|
|
499
578
|
|
|
500
579
|
|
|
@@ -535,6 +614,44 @@ is_in_trash(){
|
|
|
535
614
|
}
|
|
536
615
|
|
|
537
616
|
|
|
617
|
+
SAFE_RM_SCOPE_ROOT=
|
|
618
|
+
init_safe_rm_scope(){
|
|
619
|
+
if [[ -z "$SAFE_RM_SCOPE" ]]; then
|
|
620
|
+
return 0
|
|
621
|
+
fi
|
|
622
|
+
|
|
623
|
+
SAFE_RM_SCOPE_ROOT=$(resolve_directory_path "$SAFE_RM_SCOPE") || {
|
|
624
|
+
error "$COMMAND: invalid SAFE_RM_SCOPE '$SAFE_RM_SCOPE': not an existing directory"
|
|
625
|
+
return 1
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
debug "$LINENO: safe rm scope enabled: $SAFE_RM_SCOPE_ROOT"
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
is_in_scope(){
|
|
633
|
+
local target_abs
|
|
634
|
+
|
|
635
|
+
if [[ -z "$SAFE_RM_SCOPE_ROOT" ]]; then
|
|
636
|
+
return 0
|
|
637
|
+
fi
|
|
638
|
+
|
|
639
|
+
target_abs=$(get_absolute_path "$1") || return 1
|
|
640
|
+
|
|
641
|
+
[[ "$target_abs" == "$SAFE_RM_SCOPE_ROOT" || "$target_abs" == "$SAFE_RM_SCOPE_ROOT"/* ]]
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
ensure_safe_scope(){
|
|
646
|
+
if is_in_scope "$1"; then
|
|
647
|
+
return 0
|
|
648
|
+
fi
|
|
649
|
+
|
|
650
|
+
error "$COMMAND: target '$1' skipped, unsafe directory scope"
|
|
651
|
+
return 1
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
538
655
|
# Returns
|
|
539
656
|
# - 0: the target is protected
|
|
540
657
|
# - 1: the target is not protected
|
|
@@ -806,6 +923,8 @@ list_files(){
|
|
|
806
923
|
# debug: get $FILE_NAME array length
|
|
807
924
|
debug "$LINENO: ${#FILE_NAME[@]} files or directory to process: ${FILE_NAME[@]}"
|
|
808
925
|
|
|
926
|
+
init_safe_rm_scope || do_exit $LINENO 1
|
|
927
|
+
|
|
809
928
|
# test remove interactive_once: ask for 3 or more files or with recursive option
|
|
810
929
|
if [[ (${#FILE_NAME[@]} > 3 || $OPT_RECURSIVE == 1) && $OPT_INTERACTIVE_ONCE == 1 ]]; then
|
|
811
930
|
echo -n "$COMMAND: remove all arguments? "
|
|
@@ -821,6 +940,11 @@ fi
|
|
|
821
940
|
for file in "${FILE_NAME[@]}"; do
|
|
822
941
|
debug "$LINENO: result file $file"
|
|
823
942
|
|
|
943
|
+
if ! ensure_safe_scope "$file"; then
|
|
944
|
+
EXIT_CODE=1
|
|
945
|
+
continue
|
|
946
|
+
fi
|
|
947
|
+
|
|
824
948
|
if [[ $file == "/" ]]; then
|
|
825
949
|
error "it is dangerous to operate recursively on /"
|
|
826
950
|
error "are you insane?"
|
package/package.json
CHANGED
package/test/cases.js
CHANGED
|
@@ -402,6 +402,149 @@ module.exports = (
|
|
|
402
402
|
])
|
|
403
403
|
})
|
|
404
404
|
|
|
405
|
+
!is_rm(type) && test(`scope "." allows removing targets under cwd only`, async t => {
|
|
406
|
+
const {
|
|
407
|
+
root,
|
|
408
|
+
source_path,
|
|
409
|
+
createDir,
|
|
410
|
+
createFile,
|
|
411
|
+
runRm,
|
|
412
|
+
pathExists
|
|
413
|
+
} = t.context
|
|
414
|
+
|
|
415
|
+
const trash = await createDir({
|
|
416
|
+
name: 'scope-trash-dot',
|
|
417
|
+
under: root
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const cwd = await createDir({
|
|
421
|
+
name: 'scope-cwd',
|
|
422
|
+
under: source_path
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
const inside = await createDir({
|
|
426
|
+
name: 'foo',
|
|
427
|
+
under: cwd
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
await createFile({
|
|
431
|
+
name: 'bar',
|
|
432
|
+
under: inside
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const resultInside = await runRm(['-rf', 'foo'], {
|
|
436
|
+
cwd,
|
|
437
|
+
env: {
|
|
438
|
+
SAFE_RM_SCOPE: '.',
|
|
439
|
+
SAFE_RM_TRASH: trash
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
assertEmptySuccess(t, resultInside)
|
|
444
|
+
t.false(await pathExists(inside), 'in-scope target should be removed')
|
|
445
|
+
|
|
446
|
+
const resultOutside = await runRm(['-rf', '../'], {
|
|
447
|
+
cwd,
|
|
448
|
+
env: {
|
|
449
|
+
SAFE_RM_SCOPE: '.',
|
|
450
|
+
SAFE_RM_TRASH: trash
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
t.is(resultOutside.code, 1, 'out-of-scope target should fail')
|
|
455
|
+
t.true(resultOutside.stderr.includes('unsafe directory scope'))
|
|
456
|
+
t.true(await pathExists(source_path), 'parent directory should remain')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
!is_rm(type) && test(`scope "~" expands HOME and blocks targets outside home`, async t => {
|
|
460
|
+
const {
|
|
461
|
+
root,
|
|
462
|
+
createDir,
|
|
463
|
+
createFile,
|
|
464
|
+
runRm,
|
|
465
|
+
pathExists
|
|
466
|
+
} = t.context
|
|
467
|
+
|
|
468
|
+
const home = await createDir({
|
|
469
|
+
name: 'scope-home',
|
|
470
|
+
under: root
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const trash = await createDir({
|
|
474
|
+
name: 'scope-trash-home',
|
|
475
|
+
under: root
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
const inside = await createFile({
|
|
479
|
+
name: 'inside-home',
|
|
480
|
+
under: home
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
const outside = await createFile({
|
|
484
|
+
name: 'outside-home'
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
const env = {
|
|
488
|
+
HOME: home,
|
|
489
|
+
SAFE_RM_SCOPE: '~',
|
|
490
|
+
SAFE_RM_TRASH: trash
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const resultInside = await runRm([inside], {
|
|
494
|
+
env
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
assertEmptySuccess(t, resultInside)
|
|
498
|
+
t.false(await pathExists(inside), 'home-scoped target should be removed')
|
|
499
|
+
|
|
500
|
+
const resultOutside = await runRm([outside], {
|
|
501
|
+
env
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
t.is(resultOutside.code, 1, 'target outside home scope should fail')
|
|
505
|
+
t.true(resultOutside.stderr.includes('unsafe directory scope'))
|
|
506
|
+
t.true(await pathExists(outside), 'out-of-scope file should remain')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
!is_rm(type) && test(`scope checks symlink path instead of symlink target`, async t => {
|
|
510
|
+
const {
|
|
511
|
+
root,
|
|
512
|
+
createDir,
|
|
513
|
+
createFile,
|
|
514
|
+
runRm,
|
|
515
|
+
pathExists
|
|
516
|
+
} = t.context
|
|
517
|
+
|
|
518
|
+
const trash = await createDir({
|
|
519
|
+
name: 'scope-trash-link',
|
|
520
|
+
under: root
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
const cwd = await createDir({
|
|
524
|
+
name: 'scope-link-cwd',
|
|
525
|
+
under: root
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
const outsideTarget = await createFile({
|
|
529
|
+
name: 'scope-link-target'
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const link = path.join(cwd, 'scope-link')
|
|
533
|
+
await fs.symlink(outsideTarget, link)
|
|
534
|
+
|
|
535
|
+
const result = await runRm(['scope-link'], {
|
|
536
|
+
cwd,
|
|
537
|
+
env: {
|
|
538
|
+
SAFE_RM_SCOPE: '.',
|
|
539
|
+
SAFE_RM_TRASH: trash
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
assertEmptySuccess(t, result)
|
|
544
|
+
t.false(await pathExists(link), 'symlink should be removed')
|
|
545
|
+
t.true(await pathExists(outsideTarget), 'symlink target should remain')
|
|
546
|
+
})
|
|
547
|
+
|
|
405
548
|
!is_rm(type) && !is_as(type) && test(`-I only prompts for more than three files`, async t => {
|
|
406
549
|
const {
|
|
407
550
|
createFile,
|
|
@@ -832,4 +975,78 @@ fi
|
|
|
832
975
|
t.is(result.code, 0, 'exit code should be 0')
|
|
833
976
|
t.false(await pathExists(filepath), 'file should be removed')
|
|
834
977
|
})
|
|
978
|
+
|
|
979
|
+
// Flag-after-operand parsing: align with the host's real `rm`.
|
|
980
|
+
// GNU rm (Linux) permutes options; BSD rm (MacOS) does not.
|
|
981
|
+
!is_rm(type) && !is_as(type) && !IS_MACOS && test(`Linux: flags after operand parse as options`, async t => {
|
|
982
|
+
const {
|
|
983
|
+
createDir,
|
|
984
|
+
createFile,
|
|
985
|
+
runRm,
|
|
986
|
+
pathExists
|
|
987
|
+
} = t.context
|
|
988
|
+
|
|
989
|
+
const dirpath = await createDir()
|
|
990
|
+
await createFile({under: dirpath})
|
|
991
|
+
|
|
992
|
+
const result = await runRm([dirpath, '-rf'])
|
|
993
|
+
|
|
994
|
+
assertEmptySuccess(t, result)
|
|
995
|
+
t.false(await pathExists(dirpath), 'directory should be removed')
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
!is_rm(type) && !is_as(type) && IS_MACOS && test(`MacOS: flags after operand are treated as filenames`, async t => {
|
|
999
|
+
const {
|
|
1000
|
+
createDir,
|
|
1001
|
+
createFile,
|
|
1002
|
+
runRm,
|
|
1003
|
+
pathExists
|
|
1004
|
+
} = t.context
|
|
1005
|
+
|
|
1006
|
+
const dirpath = await createDir()
|
|
1007
|
+
await createFile({under: dirpath})
|
|
1008
|
+
|
|
1009
|
+
const result = await runRm([dirpath, '-rf'])
|
|
1010
|
+
|
|
1011
|
+
t.is(result.code, 1, 'exit code should be 1')
|
|
1012
|
+
t.true(await pathExists(dirpath), 'directory should remain')
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
!is_rm(type) && !is_as(type) && IS_MACOS && test(`SAFE_RM_OPTIONS_ANYWHERE=yes enables permutation on MacOS`, async t => {
|
|
1016
|
+
const {
|
|
1017
|
+
createDir,
|
|
1018
|
+
createFile,
|
|
1019
|
+
runRm,
|
|
1020
|
+
pathExists
|
|
1021
|
+
} = t.context
|
|
1022
|
+
|
|
1023
|
+
const dirpath = await createDir()
|
|
1024
|
+
await createFile({under: dirpath})
|
|
1025
|
+
|
|
1026
|
+
const result = await runRm([dirpath, '-rf'], {
|
|
1027
|
+
env: {SAFE_RM_OPTIONS_ANYWHERE: 'yes'}
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
assertEmptySuccess(t, result)
|
|
1031
|
+
t.false(await pathExists(dirpath), 'directory should be removed')
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
!is_rm(type) && !is_as(type) && !IS_MACOS && test(`SAFE_RM_OPTIONS_ANYWHERE=no disables permutation on Linux`, async t => {
|
|
1035
|
+
const {
|
|
1036
|
+
createDir,
|
|
1037
|
+
createFile,
|
|
1038
|
+
runRm,
|
|
1039
|
+
pathExists
|
|
1040
|
+
} = t.context
|
|
1041
|
+
|
|
1042
|
+
const dirpath = await createDir()
|
|
1043
|
+
await createFile({under: dirpath})
|
|
1044
|
+
|
|
1045
|
+
const result = await runRm([dirpath, '-rf'], {
|
|
1046
|
+
env: {SAFE_RM_OPTIONS_ANYWHERE: 'no'}
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
t.is(result.code, 1, 'exit code should be 1')
|
|
1050
|
+
t.true(await pathExists(dirpath), 'directory should remain')
|
|
1051
|
+
})
|
|
835
1052
|
}
|