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 +68 -42
- package/package.json +1 -1
- package/test/cases.js +252 -0
- package/test/helper.js +4 -2
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
|
-
|
|
435
|
-
|
|
436
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
617
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
696
|
-
list_files "$
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
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 = ''
|