safe-rm 3.1.2 → 3.1.4

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/bin/rm.sh CHANGED
@@ -429,17 +429,24 @@ remove(){
429
429
 
430
430
 
431
431
  recursive_remove(){
432
+ local dir=$1
432
433
  local path
433
-
434
- # use `ls -A` instead of `for` to list hidden files.
435
- # and `for $1/*` is also weird if `$1` is neithor a dir nor existing that will print "$1/*" directly and rudely.
436
- # never use `find $1`, for the searching order is not what we want
437
- local list=$(ls -A "$1")
438
-
439
- [[ -n $list ]] && for path in $list; do
440
- debug "$LINENO: recursively remove: $1/$path"
441
-
442
- remove "$1/$path"
434
+ local restore_nullglob=$(shopt -p nullglob)
435
+ local restore_dotglob=$(shopt -p dotglob)
436
+
437
+ # Avoid `ls -A` + unquoted `for` iteration, which splits names by IFS
438
+ # and breaks paths containing spaces (e.g. "a b" -> "a" + "b").
439
+ # Use glob expansion with arrays to keep each entry as one element.
440
+ # dotglob includes hidden files, nullglob avoids a literal "$dir/*" token.
441
+ shopt -s nullglob dotglob
442
+ local list=("$dir"/*)
443
+ eval "$restore_nullglob"
444
+ eval "$restore_dotglob"
445
+
446
+ for path in "${list[@]}"; do
447
+ debug "$LINENO: recursively remove: $path"
448
+
449
+ remove "$path"
443
450
  done
444
451
  }
445
452
 
@@ -483,7 +490,11 @@ do_trash(){
483
490
 
484
491
 
485
492
  get_absolute_path(){
486
- echo $(cd "$(dirname "$1")" && pwd)/$(basename "$1")
493
+ local dir
494
+ local base
495
+ dir=$(cd "$(dirname -- "$1")" && pwd)
496
+ base=$(basename -- "$1")
497
+ printf '%s/%s\n' "$dir" "$base"
487
498
  }
488
499
 
489
500
 
@@ -521,12 +532,19 @@ applescript_trash(){
521
532
 
522
533
  [[ "$OPT_VERBOSE" == 1 ]] && list_files "$target"
523
534
 
535
+ # #47: Finder alias resolves symlinks to their targets.
536
+ # For symbolic links, fallback to `mv`-based trash to remove the link itself.
537
+ if [[ -L "$target" ]]; then
538
+ debug "$LINENO: symlink detected, fallback to mac_trash: $target"
539
+ mac_trash "$target"
540
+ return $?
541
+ fi
542
+
524
543
  debug "$LINENO: osascript delete $target"
525
544
 
526
545
  osascript -e "tell application \"Finder\" to delete (POSIX file \"$target\" as alias)" &> /dev/null
527
546
 
528
- # TODO: handle osascript errors
529
- return 0
547
+ return $?
530
548
  }
531
549
 
532
550
 
@@ -587,7 +605,7 @@ check_target_to_move(){
587
605
  mac_trash(){
588
606
  check_target_to_move "$1"
589
607
  local move=$_to_move
590
- local base=$(basename "$move")
608
+ local base=$(basename -- "$move")
591
609
 
592
610
  # foo.jpg => "foo" + ".jpg"
593
611
  # foo => "foo" + ""
@@ -609,12 +627,13 @@ mac_trash(){
609
627
  [[ "$OPT_VERBOSE" == 1 ]] && list_files "$1"
610
628
 
611
629
  debug "$LINENO: mv $move to $trash_path"
612
- mv "$move" "$trash_path"
630
+ mv -- "$move" "$trash_path"
631
+ local status=$?
613
632
 
614
- [[ "$_traveled" == 1 ]] && cd $__DIRNAME &> /dev/null
633
+ [[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
615
634
 
616
- # default status
617
- return 0
635
+ # Propagate mv status; otherwise callers may treat move failures as success.
636
+ return $status
618
637
  }
619
638
 
620
639
 
@@ -657,7 +676,7 @@ check_linux_trash_base(){
657
676
  linux_trash(){
658
677
  check_target_to_move "$1"
659
678
  local move=$_to_move
660
- local base=$(basename "$move")
679
+ local base=$(basename -- "$move")
661
680
 
662
681
  base=$(check_linux_trash_base "$base")
663
682
 
@@ -667,7 +686,14 @@ linux_trash(){
667
686
 
668
687
  # Move the target into the trash
669
688
  debug "$LINENO: mv $move to $trash_path"
670
- mv "$move" "$trash_path"
689
+ mv -- "$move" "$trash_path"
690
+ local move_status=$?
691
+
692
+ if [[ $move_status -ne 0 ]]; then
693
+ # Keep failure visible to remove()/EXIT_CODE and skip writing .trashinfo.
694
+ [[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
695
+ return $move_status
696
+ fi
671
697
 
672
698
  # Save linux trash info
673
699
  local info_path="$SAFE_RM_TRASH/info/$base.trashinfo"
@@ -677,10 +703,11 @@ linux_trash(){
677
703
  Path=$move
678
704
  DeletionDate=$trash_time
679
705
  EOF
706
+ local info_status=$?
680
707
 
681
- [[ "$_traveled" == 1 ]] && cd $__DIRNAME &> /dev/null
708
+ [[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
682
709
 
683
- return 0
710
+ return $info_status
684
711
  }
685
712
 
686
713
 
@@ -689,15 +716,22 @@ EOF
689
716
  # 'coz `find` act a inward searching, unlike rm -v
690
717
  list_files(){
691
718
  if [[ -d "$1" ]]; then
692
- local list=$(ls -A "$1")
719
+ local restore_nullglob=$(shopt -p nullglob)
720
+ local restore_dotglob=$(shopt -p dotglob)
721
+ # Keep traversal behavior aligned with recursive_remove(): no word splitting
722
+ # for names with spaces, and include dotfiles for rm -v output.
723
+ shopt -s nullglob dotglob
724
+ local list=("$1"/*)
725
+ eval "$restore_nullglob"
726
+ eval "$restore_dotglob"
693
727
  local f
694
728
 
695
- [[ -n $list ]] && for f in $list; do
696
- list_files "$1/$f"
729
+ for f in "${list[@]}"; do
730
+ list_files "$f"
697
731
  done
698
732
  fi
699
733
 
700
- echo $1
734
+ echo "$1"
701
735
  }
702
736
 
703
737
 
@@ -733,28 +767,20 @@ for file in "${FILE_NAME[@]}"; do
733
767
  fi
734
768
 
735
769
  # the same check also apply on /. /..
736
- if [[ $(basename "$file") == "." || $(basename "$file") == ".." ]]; then
770
+ if [[ $(basename -- "$file") == "." || $(basename -- "$file") == ".." ]]; then
737
771
  error "$COMMAND: \".\" and \"..\" may not be removed"
738
772
  EXIT_CODE=1
739
773
  continue
740
774
  fi
741
775
 
742
- # deal with wildcard and also, redirect error output
743
- ls_result=$(ls -d "$file" 2> /dev/null)
744
-
745
- # debug
746
- debug "$LINENO: ls_result: $ls_result"
747
-
748
- if [[ -n "$ls_result" ]]; then
749
- for file in "$ls_result"; do
750
- remove "$file"
751
- status=$?
752
- debug "$LINENO: remove returned status: $status"
776
+ if [[ -e "$file" || -L "$file" ]]; then
777
+ remove "$file"
778
+ status=$?
779
+ debug "$LINENO: remove returned status: $status"
753
780
 
754
- if [[ ! $status == 0 ]]; then
755
- EXIT_CODE=1
756
- fi
757
- done
781
+ if [[ ! $status == 0 ]]; then
782
+ EXIT_CODE=1
783
+ fi
758
784
  elif [[ -z "$OPT_FORCE" ]]; then
759
785
  error "$COMMAND: $file: No such file or directory" >&2
760
786
  EXIT_CODE=1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-rm",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
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
@@ -1,4 +1,5 @@
1
1
  const path = require('path')
2
+ const fs = require('fs').promises
2
3
  const {v4: uuid} = require('uuid')
3
4
  const delay = require('delay')
4
5
 
@@ -315,4 +316,255 @@ remove ${dir}? y
315
316
 
316
317
  t.false(await pathExists(dir), 'directory should be removed')
317
318
  })
319
+
320
+ !is_as(type) && test(`recursively and interactively with spaces`, async t => {
321
+ // Regression guard: recursive interactive remove must keep "a b"
322
+ // as one path and still remove the directory successfully.
323
+ const {
324
+ createDir,
325
+ createFile,
326
+ runRm,
327
+ pathExists
328
+ } = t.context
329
+
330
+ const dir = await createDir({
331
+ name: 'foo'
332
+ })
333
+
334
+ const fileA = await createFile({
335
+ name: 'a b',
336
+ under: dir
337
+ })
338
+
339
+ const fileB = await createFile({
340
+ name: 'c',
341
+ under: dir
342
+ })
343
+
344
+ const result = await runRm(['-ri', dir], {
345
+ input: ['y', 'y', 'y', 'y']
346
+ })
347
+
348
+ t.is(result.stderr, '', 'stderr should be empty')
349
+ t.is(result.code, 0, 'exit code should be 0')
350
+ t.is(
351
+ result.stdout,
352
+ `examine files in directory ${dir}? y
353
+ remove ${fileA}? y
354
+ remove ${fileB}? y
355
+ remove ${dir}? y
356
+ `,
357
+ 'stdout does not match'
358
+ )
359
+
360
+ t.false(await pathExists(dir), 'directory should be removed')
361
+ })
362
+
363
+ !is_as(type) && test(`#47: removes symlink itself instead of target`, async t => {
364
+ const {
365
+ createFile,
366
+ runRm,
367
+ pathExists
368
+ } = t.context
369
+
370
+ const target = await createFile({
371
+ name: 'file1'
372
+ })
373
+
374
+ const link = path.join(path.dirname(target), 'file1_sym')
375
+ await fs.symlink(target, link)
376
+
377
+ const result = await runRm([link])
378
+
379
+ t.is(result.code, 0, 'exit code should be 0')
380
+ t.false(await pathExists(link), 'symlink should be removed')
381
+ t.true(await pathExists(target), 'target file should remain')
382
+ })
383
+
384
+ !is_as(type) && test(`#47: applescript path should also remove symlink itself`, async t => {
385
+ const {
386
+ root,
387
+ createDir,
388
+ createFile,
389
+ runRm,
390
+ pathExists
391
+ } = t.context
392
+
393
+ const home = await createDir({
394
+ name: 'home',
395
+ under: root
396
+ })
397
+
398
+ await createDir({
399
+ name: '.Trash',
400
+ under: home
401
+ })
402
+
403
+ const mockBin = await createDir({
404
+ name: 'mock-bin',
405
+ under: root
406
+ })
407
+
408
+ // Simulate Finder alias behavior: if safe-rm calls osascript with a symlink,
409
+ // this mock deletes the resolved target. safe-rm should bypass this for symlinks.
410
+ const mockOsa = await createFile({
411
+ name: 'osascript',
412
+ under: mockBin,
413
+ content: `#!/usr/bin/env bash
414
+ expr=$2
415
+ target=$(printf '%s' "$expr" | cut -d '"' -f2)
416
+
417
+ if [[ -L "$target" ]]; then
418
+ resolved=$(readlink "$target")
419
+ if [[ "$resolved" != /* ]]; then
420
+ resolved="$(cd "$(dirname "$target")" && pwd)/$resolved"
421
+ fi
422
+ /bin/rm -f "$resolved"
423
+ else
424
+ /bin/rm -rf "$target"
425
+ fi
426
+ `
427
+ })
428
+
429
+ await fs.chmod(mockOsa, 0o755)
430
+
431
+ const target = await createFile({
432
+ name: 'file2'
433
+ })
434
+
435
+ const link = path.join(path.dirname(target), 'file2_sym')
436
+ await fs.symlink(target, link)
437
+
438
+ const result = await runRm([link], {
439
+ env: {
440
+ HOME: home,
441
+ PATH: `${mockBin}:${process.env.PATH}`,
442
+ SAFE_RM_TRASH: ''
443
+ }
444
+ })
445
+
446
+ t.is(result.code, 0, 'exit code should be 0')
447
+ t.false(await pathExists(link), 'symlink should be removed')
448
+ t.true(await pathExists(target), 'target file should remain')
449
+ })
450
+
451
+ !is_as(type) && test(`removes a dangling symlink`, async t => {
452
+ const {
453
+ source_path,
454
+ runRm
455
+ } = t.context
456
+
457
+ const missingTarget = path.join(source_path, 'missing-target')
458
+ const link = path.join(source_path, 'dangling_sym')
459
+ await fs.symlink(missingTarget, link)
460
+
461
+ const before = await fs.lstat(link)
462
+ t.true(before.isSymbolicLink(), 'should create symlink before remove')
463
+
464
+ const result = await runRm([link])
465
+
466
+ t.is(result.code, 0, 'exit code should be 0')
467
+ await t.throwsAsync(async () => fs.lstat(link), {
468
+ code: 'ENOENT'
469
+ })
470
+ })
471
+
472
+ !is_as(type) && test(`-r should not dereference a directory symlink`, async t => {
473
+ const {
474
+ createDir,
475
+ createFile,
476
+ runRm,
477
+ pathExists
478
+ } = t.context
479
+
480
+ const realDir = await createDir({
481
+ name: 'real-dir'
482
+ })
483
+
484
+ const keep = await createFile({
485
+ name: 'keep',
486
+ under: realDir
487
+ })
488
+
489
+ const link = path.join(path.dirname(realDir), 'real-dir-sym')
490
+ await fs.symlink(realDir, link)
491
+
492
+ const result = await runRm(['-r', link])
493
+
494
+ t.is(result.code, 0, 'exit code should be 0')
495
+ await t.throwsAsync(async () => fs.lstat(link), {
496
+ code: 'ENOENT'
497
+ })
498
+ t.true(await pathExists(realDir), 'target directory should remain')
499
+ t.true(await pathExists(keep), 'target content should remain')
500
+ })
501
+
502
+ !is_as(type) && test(`-ri should not dereference a directory symlink`, async t => {
503
+ const {
504
+ createDir,
505
+ createFile,
506
+ runRm,
507
+ pathExists
508
+ } = t.context
509
+
510
+ const realDir = await createDir({
511
+ name: 'real-dir-i'
512
+ })
513
+
514
+ const keep = await createFile({
515
+ name: 'keep-i',
516
+ under: realDir
517
+ })
518
+
519
+ const link = path.join(path.dirname(realDir), 'real-dir-sym-i')
520
+ await fs.symlink(realDir, link)
521
+
522
+ const result = await runRm(['-ri', link], {
523
+ input: ['y']
524
+ })
525
+
526
+ t.is(result.code, 0, 'exit code should be 0')
527
+ await t.throwsAsync(async () => fs.lstat(link), {
528
+ code: 'ENOENT'
529
+ })
530
+ t.true(await pathExists(realDir), 'target directory should remain')
531
+ t.true(await pathExists(keep), 'target content should remain')
532
+ })
533
+
534
+ !is_as(type) && test(`removes a file prefixed with "-" after --`, async t => {
535
+ const {
536
+ source_path,
537
+ createFile,
538
+ runRm,
539
+ pathExists
540
+ } = t.context
541
+
542
+ const name = '-dash-file'
543
+ await createFile({name})
544
+
545
+ const result = await runRm(['--', name], {
546
+ cwd: source_path
547
+ })
548
+
549
+ t.is(result.code, 0, 'exit code should be 0')
550
+ t.false(await pathExists(name), 'file should be removed')
551
+ })
552
+
553
+ !is_as(type) && test(`removes a file with newline in filename`, async t => {
554
+ const {
555
+ createFile,
556
+ runRm,
557
+ pathExists
558
+ } = t.context
559
+
560
+ const filename = 'line1\nline2'
561
+ const filepath = await createFile({
562
+ name: filename
563
+ })
564
+
565
+ const result = await runRm([filepath])
566
+
567
+ t.is(result.code, 0, 'exit code should be 0')
568
+ t.false(await pathExists(filepath), 'file should be removed')
569
+ })
318
570
  }
package/test/helper.js CHANGED
@@ -60,7 +60,8 @@ const generateContextMethods = (
60
60
  function runRm (args, {
61
61
  input = [],
62
62
  command = rm_command,
63
- env: arg_env = {}
63
+ env: arg_env = {},
64
+ cwd
64
65
  } = {}) {
65
66
  return new Promise((resolve, reject) => {
66
67
  const env = {
@@ -73,7 +74,8 @@ const generateContextMethods = (
73
74
  }
74
75
 
75
76
  const child = spawn(command, args, {
76
- env
77
+ env,
78
+ cwd
77
79
  })
78
80
  let stdout = ''
79
81
  let stderr = ''