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.
@@ -23,5 +23,6 @@ jobs:
23
23
  npm install
24
24
  npm run build --if-present
25
25
  npm run test:dev
26
+ npm run test:mock-linux
26
27
  env:
27
28
  CI: true
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
- dir=$(cd "$(dirname -- "$1")" && pwd)
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
- printf '%s/%s\n' "$dir" "$base"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-rm",
3
- "version": "3.1.5",
3
+ "version": "3.3.0",
4
4
  "description": "A much safer replacement of bash rm with nearly full functionalities and options of the rm command!",
5
5
  "bin": {
6
6
  "safe-rm": "./bin/rm.sh"
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
  }