safe-rm 3.1.4 → 3.2.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/README.md +25 -0
- package/bin/rm.sh +195 -22
- package/package.json +1 -1
- package/test/cases.js +432 -24
- package/test/helper.js +7 -2
package/README.md
CHANGED
|
@@ -161,6 +161,31 @@ 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
|
+
export SAFE_RM_SCOPE="$HOME"
|
|
177
|
+
safe-rm -rf /
|
|
178
|
+
# rm: target '/' skipped, unsafe directory scope
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
You could also use a relative scope such as `.`:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
SAFE_RM_SCOPE=. safe-rm -rf ./foo
|
|
185
|
+
SAFE_RM_SCOPE=. safe-rm -rf ../
|
|
186
|
+
# rm: target '../' skipped, unsafe directory scope
|
|
187
|
+
```
|
|
188
|
+
|
|
164
189
|
### Protect Files And Directories From Deleting
|
|
165
190
|
|
|
166
191
|
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
|
|
|
@@ -330,7 +341,7 @@ if [[ ! -e $SAFE_RM_TRASH ]]; then
|
|
|
330
341
|
echo -n "(yes/no): "
|
|
331
342
|
|
|
332
343
|
read answer
|
|
333
|
-
if [[ $answer == "yes" || ! -n $
|
|
344
|
+
if [[ $answer == "yes" || ! -n $answer ]]; then
|
|
334
345
|
mkdir -p "$SAFE_RM_TRASH"
|
|
335
346
|
else
|
|
336
347
|
echo "Canceled!"
|
|
@@ -351,6 +362,8 @@ check_return_status(){
|
|
|
351
362
|
remove(){
|
|
352
363
|
local file=$1
|
|
353
364
|
|
|
365
|
+
ensure_safe_scope "$file" || return 1
|
|
366
|
+
|
|
354
367
|
# if is dir
|
|
355
368
|
if [[ -d "$file" && ! -L "$file" ]]; then
|
|
356
369
|
|
|
@@ -455,7 +468,7 @@ trash(){
|
|
|
455
468
|
local target=$1
|
|
456
469
|
|
|
457
470
|
if [[ -n $SAFE_RM_PERM_DEL_FILES_IN_TRASH ]]; then
|
|
458
|
-
if
|
|
471
|
+
if is_in_trash "$target"; then
|
|
459
472
|
# If the target is already in the trash, delete it permanently
|
|
460
473
|
/bin/rm -rf "$target"
|
|
461
474
|
else
|
|
@@ -492,9 +505,131 @@ do_trash(){
|
|
|
492
505
|
get_absolute_path(){
|
|
493
506
|
local dir
|
|
494
507
|
local base
|
|
495
|
-
|
|
508
|
+
if [[ "$1" == "/" ]]; then
|
|
509
|
+
printf '/\n'
|
|
510
|
+
return
|
|
511
|
+
fi
|
|
512
|
+
|
|
513
|
+
dir=$(cd "$(dirname -- "$1")" && pwd) || return 1
|
|
496
514
|
base=$(basename -- "$1")
|
|
497
|
-
|
|
515
|
+
|
|
516
|
+
case "$base" in
|
|
517
|
+
.)
|
|
518
|
+
printf '%s\n' "$dir"
|
|
519
|
+
;;
|
|
520
|
+
|
|
521
|
+
..)
|
|
522
|
+
dir=$(cd "$dir/.." && pwd) || return 1
|
|
523
|
+
printf '%s\n' "$dir"
|
|
524
|
+
;;
|
|
525
|
+
|
|
526
|
+
*)
|
|
527
|
+
printf '%s/%s\n' "$dir" "$base"
|
|
528
|
+
;;
|
|
529
|
+
esac
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
expand_home_path(){
|
|
534
|
+
case $1 in
|
|
535
|
+
"~")
|
|
536
|
+
printf '%s\n' "$HOME"
|
|
537
|
+
;;
|
|
538
|
+
|
|
539
|
+
"~/"*)
|
|
540
|
+
printf '%s/%s\n' "$HOME" "${1#~/}"
|
|
541
|
+
;;
|
|
542
|
+
|
|
543
|
+
*)
|
|
544
|
+
printf '%s\n' "$1"
|
|
545
|
+
;;
|
|
546
|
+
esac
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
resolve_directory_path(){
|
|
551
|
+
local path
|
|
552
|
+
path=$(expand_home_path "$1")
|
|
553
|
+
|
|
554
|
+
(
|
|
555
|
+
cd "$path" &> /dev/null || exit 1
|
|
556
|
+
pwd
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
encode_trashinfo_path(){
|
|
562
|
+
local path=$1
|
|
563
|
+
local out=
|
|
564
|
+
local i
|
|
565
|
+
local char
|
|
566
|
+
local hex
|
|
567
|
+
local LC_ALL=C
|
|
568
|
+
|
|
569
|
+
for ((i = 0; i < ${#path}; i += 1)); do
|
|
570
|
+
char=${path:i:1}
|
|
571
|
+
|
|
572
|
+
case "$char" in
|
|
573
|
+
[a-zA-Z0-9._~/-])
|
|
574
|
+
out="$out$char"
|
|
575
|
+
;;
|
|
576
|
+
*)
|
|
577
|
+
hex=$(printf '%s' "$char" | od -An -tx1 | tr -d ' \n' | tr '[:lower:]' '[:upper:]')
|
|
578
|
+
out="$out%$hex"
|
|
579
|
+
;;
|
|
580
|
+
esac
|
|
581
|
+
done
|
|
582
|
+
|
|
583
|
+
printf '%s\n' "$out"
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
is_in_trash(){
|
|
588
|
+
local target_abs
|
|
589
|
+
local trash_abs
|
|
590
|
+
|
|
591
|
+
target_abs=$(get_absolute_path "$1") || return 1
|
|
592
|
+
trash_abs=$(cd "$SAFE_RM_TRASH" && pwd) || return 1
|
|
593
|
+
|
|
594
|
+
[[ "$target_abs" == "$trash_abs" || "$target_abs" == "$trash_abs"/* ]]
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
SAFE_RM_SCOPE_ROOT=
|
|
599
|
+
init_safe_rm_scope(){
|
|
600
|
+
if [[ -z "$SAFE_RM_SCOPE" ]]; then
|
|
601
|
+
return 0
|
|
602
|
+
fi
|
|
603
|
+
|
|
604
|
+
SAFE_RM_SCOPE_ROOT=$(resolve_directory_path "$SAFE_RM_SCOPE") || {
|
|
605
|
+
error "$COMMAND: invalid SAFE_RM_SCOPE '$SAFE_RM_SCOPE': not an existing directory"
|
|
606
|
+
return 1
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
debug "$LINENO: safe rm scope enabled: $SAFE_RM_SCOPE_ROOT"
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
is_in_scope(){
|
|
614
|
+
local target_abs
|
|
615
|
+
|
|
616
|
+
if [[ -z "$SAFE_RM_SCOPE_ROOT" ]]; then
|
|
617
|
+
return 0
|
|
618
|
+
fi
|
|
619
|
+
|
|
620
|
+
target_abs=$(get_absolute_path "$1") || return 1
|
|
621
|
+
|
|
622
|
+
[[ "$target_abs" == "$SAFE_RM_SCOPE_ROOT" || "$target_abs" == "$SAFE_RM_SCOPE_ROOT"/* ]]
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
ensure_safe_scope(){
|
|
627
|
+
if is_in_scope "$1"; then
|
|
628
|
+
return 0
|
|
629
|
+
fi
|
|
630
|
+
|
|
631
|
+
error "$COMMAND: target '$1' skipped, unsafe directory scope"
|
|
632
|
+
return 1
|
|
498
633
|
}
|
|
499
634
|
|
|
500
635
|
|
|
@@ -559,16 +694,22 @@ check_mac_trash_path(){
|
|
|
559
694
|
local ext=$2
|
|
560
695
|
local full_path="$path$ext"
|
|
561
696
|
|
|
562
|
-
|
|
563
|
-
if [[ -e "$full_path" ]]; then
|
|
564
|
-
debug "$LINENO: $full_path already exists"
|
|
565
|
-
|
|
566
|
-
# renew $_short_time_ret
|
|
567
|
-
short_time
|
|
568
|
-
check_mac_trash_path "$path $_short_time_ret" "$ext"
|
|
569
|
-
else
|
|
697
|
+
if [[ ! -e "$full_path" ]]; then
|
|
570
698
|
_mac_trash_path_ret=$full_path
|
|
699
|
+
return
|
|
571
700
|
fi
|
|
701
|
+
|
|
702
|
+
debug "$LINENO: $full_path already exists"
|
|
703
|
+
|
|
704
|
+
short_time
|
|
705
|
+
full_path="$path $_short_time_ret$ext"
|
|
706
|
+
|
|
707
|
+
while [[ -e "$full_path" ]]; do
|
|
708
|
+
debug "$LINENO: $full_path already exists"
|
|
709
|
+
full_path="${full_path}X"
|
|
710
|
+
done
|
|
711
|
+
|
|
712
|
+
_mac_trash_path_ret=$full_path
|
|
572
713
|
}
|
|
573
714
|
|
|
574
715
|
|
|
@@ -651,16 +792,35 @@ check_linux_trash_base(){
|
|
|
651
792
|
|
|
652
793
|
local max_n=0
|
|
653
794
|
local num=
|
|
795
|
+
local restore_nullglob=$(shopt -p nullglob)
|
|
796
|
+
local restore_dotglob=$(shopt -p dotglob)
|
|
797
|
+
shopt -s nullglob dotglob
|
|
798
|
+
local list=("$trash"/*)
|
|
799
|
+
eval "$restore_nullglob"
|
|
800
|
+
eval "$restore_dotglob"
|
|
801
|
+
local file
|
|
802
|
+
local name
|
|
803
|
+
local suffix
|
|
654
804
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
max_n=$num
|
|
661
|
-
fi
|
|
805
|
+
for file in "${list[@]}"; do
|
|
806
|
+
name=$(basename -- "$file")
|
|
807
|
+
|
|
808
|
+
if [[ "$name" != "$base".* ]]; then
|
|
809
|
+
continue
|
|
662
810
|
fi
|
|
663
|
-
|
|
811
|
+
|
|
812
|
+
suffix=${name#"$base".}
|
|
813
|
+
|
|
814
|
+
if [[ ! "$suffix" =~ ^[0-9]+$ ]]; then
|
|
815
|
+
continue
|
|
816
|
+
fi
|
|
817
|
+
|
|
818
|
+
# Remove leading zeros and make sure the number is in base 10
|
|
819
|
+
num=$((10#$suffix))
|
|
820
|
+
if ((num > max_n)); then
|
|
821
|
+
max_n=$num
|
|
822
|
+
fi
|
|
823
|
+
done
|
|
664
824
|
|
|
665
825
|
(( max_n += 1 ))
|
|
666
826
|
|
|
@@ -674,6 +834,12 @@ check_linux_trash_base(){
|
|
|
674
834
|
# trash a file or dir directly for linux
|
|
675
835
|
# - move the target into
|
|
676
836
|
linux_trash(){
|
|
837
|
+
local original_path
|
|
838
|
+
local trashinfo_path_value
|
|
839
|
+
|
|
840
|
+
original_path=$(get_absolute_path "$1") || return 1
|
|
841
|
+
trashinfo_path_value=$(encode_trashinfo_path "$original_path") || return 1
|
|
842
|
+
|
|
677
843
|
check_target_to_move "$1"
|
|
678
844
|
local move=$_to_move
|
|
679
845
|
local base=$(basename -- "$move")
|
|
@@ -700,7 +866,7 @@ linux_trash(){
|
|
|
700
866
|
local trash_time=$(date +%Y-%m-%dT%H:%M:%S)
|
|
701
867
|
cat > "$info_path" <<EOF
|
|
702
868
|
[Trash Info]
|
|
703
|
-
Path=$
|
|
869
|
+
Path=$trashinfo_path_value
|
|
704
870
|
DeletionDate=$trash_time
|
|
705
871
|
EOF
|
|
706
872
|
local info_status=$?
|
|
@@ -738,8 +904,10 @@ list_files(){
|
|
|
738
904
|
# debug: get $FILE_NAME array length
|
|
739
905
|
debug "$LINENO: ${#FILE_NAME[@]} files or directory to process: ${FILE_NAME[@]}"
|
|
740
906
|
|
|
907
|
+
init_safe_rm_scope || do_exit $LINENO 1
|
|
908
|
+
|
|
741
909
|
# test remove interactive_once: ask for 3 or more files or with recursive option
|
|
742
|
-
if [[ (${#FILE_NAME[@]} >
|
|
910
|
+
if [[ (${#FILE_NAME[@]} > 3 || $OPT_RECURSIVE == 1) && $OPT_INTERACTIVE_ONCE == 1 ]]; then
|
|
743
911
|
echo -n "$COMMAND: remove all arguments? "
|
|
744
912
|
read answer
|
|
745
913
|
|
|
@@ -753,6 +921,11 @@ fi
|
|
|
753
921
|
for file in "${FILE_NAME[@]}"; do
|
|
754
922
|
debug "$LINENO: result file $file"
|
|
755
923
|
|
|
924
|
+
if ! ensure_safe_scope "$file"; then
|
|
925
|
+
EXIT_CODE=1
|
|
926
|
+
continue
|
|
927
|
+
fi
|
|
928
|
+
|
|
756
929
|
if [[ $file == "/" ]]; then
|
|
757
930
|
error "it is dangerous to operate recursively on /"
|
|
758
931
|
error "are you insane?"
|
package/package.json
CHANGED
package/test/cases.js
CHANGED
|
@@ -20,6 +20,9 @@ const is_as = type => type === 'safe-rm-as'
|
|
|
20
20
|
// We skip testing trash dir for vanilla rm
|
|
21
21
|
const should_skip_test_trash_dir = type => is_rm(type) || is_as(type)
|
|
22
22
|
|
|
23
|
+
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
24
|
+
const encodeTrashInfoPath = value => value.split('/').map(encodeURIComponent).join('/')
|
|
25
|
+
|
|
23
26
|
module.exports = (
|
|
24
27
|
test,
|
|
25
28
|
{
|
|
@@ -111,32 +114,24 @@ module.exports = (
|
|
|
111
114
|
.sort((a, b) => a.length - b.length)
|
|
112
115
|
|
|
113
116
|
if (IS_MACOS) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
f => {
|
|
121
|
-
const base = path.basename(f)
|
|
122
|
-
|
|
123
|
-
return base.slice(0, base.length - ext.length)
|
|
124
|
-
}
|
|
117
|
+
const baseNames = files.map(file => path.basename(file))
|
|
118
|
+
const timestamped = baseNames.filter(base => base !== full_name)
|
|
119
|
+
const timestampedRe = new RegExp(
|
|
120
|
+
ext
|
|
121
|
+
? `^${escapeRegExp(filename)} \\d{2}\\.\\d{2}\\.\\d{2}${escapeRegExp(ext)}X*$`
|
|
122
|
+
: `^${escapeRegExp(filename)} \\d{2}\\.\\d{2}\\.\\d{2}X*$`
|
|
125
123
|
)
|
|
126
124
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
t.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
t.is(fbs3[1], time)
|
|
139
|
-
t.is(fbs3[2], time)
|
|
125
|
+
t.is(baseNames.filter(base => base === full_name).length, 1)
|
|
126
|
+
t.is(timestamped.length, 2)
|
|
127
|
+
t.true(
|
|
128
|
+
timestamped.every(base => timestampedRe.test(base)),
|
|
129
|
+
'timestamped duplicates should follow Finder naming'
|
|
130
|
+
)
|
|
131
|
+
t.true(
|
|
132
|
+
timestamped.some(base => ext ? base.endsWith(ext) : !base.endsWith('X')),
|
|
133
|
+
'should include a plain timestamped duplicate'
|
|
134
|
+
)
|
|
140
135
|
} else {
|
|
141
136
|
// /path/to/foo[.jpg]
|
|
142
137
|
// /path/to/foo[.jpg].1
|
|
@@ -193,6 +188,419 @@ module.exports = (
|
|
|
193
188
|
t.is(files.length, 0, 'should be already removed')
|
|
194
189
|
})
|
|
195
190
|
|
|
191
|
+
!is_rm(type) && !is_as(type) && test(`permanent delete only applies to real trash descendants`, async t => {
|
|
192
|
+
const {
|
|
193
|
+
createDir,
|
|
194
|
+
createFile,
|
|
195
|
+
runRm,
|
|
196
|
+
pathExists,
|
|
197
|
+
lsFileInTrash
|
|
198
|
+
} = t.context
|
|
199
|
+
|
|
200
|
+
const sibling = await createDir({
|
|
201
|
+
name: `${path.basename(t.context.trash_path)}-other`,
|
|
202
|
+
under: path.dirname(t.context.trash_path)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const filepath = await createFile({
|
|
206
|
+
name: uuid(),
|
|
207
|
+
under: sibling
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const result = await runRm([filepath], {
|
|
211
|
+
env: {
|
|
212
|
+
SAFE_RM_PERM_DEL_FILES_IN_TRASH: 'yes'
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
assertEmptySuccess(t, result)
|
|
217
|
+
t.false(await pathExists(filepath), 'file should be removed from the sibling dir')
|
|
218
|
+
|
|
219
|
+
const files = await lsFileInTrash(filepath)
|
|
220
|
+
|
|
221
|
+
t.is(files.length, 1, 'file should be moved into trash instead of being hard-deleted')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
!is_rm(type) && !is_as(type) && !IS_MACOS && test(`linux duplicate naming treats regex chars literally`, async t => {
|
|
225
|
+
const {
|
|
226
|
+
trash_path,
|
|
227
|
+
createDir,
|
|
228
|
+
createFile,
|
|
229
|
+
runRm,
|
|
230
|
+
pathExists,
|
|
231
|
+
lsFileInTrash
|
|
232
|
+
} = t.context
|
|
233
|
+
|
|
234
|
+
const filename = 'a+b'
|
|
235
|
+
const trashFilesDir = await createDir({
|
|
236
|
+
name: 'files',
|
|
237
|
+
under: trash_path
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const existing0 = await createFile({
|
|
241
|
+
name: filename,
|
|
242
|
+
under: trashFilesDir,
|
|
243
|
+
content: 'existing-0'
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const existing1 = await createFile({
|
|
247
|
+
name: `${filename}.1`,
|
|
248
|
+
under: trashFilesDir,
|
|
249
|
+
content: 'existing-1'
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const filepath = await createFile({
|
|
253
|
+
name: filename,
|
|
254
|
+
content: 'incoming'
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const result = await runRm([filepath])
|
|
258
|
+
|
|
259
|
+
assertEmptySuccess(t, result)
|
|
260
|
+
t.false(await pathExists(filepath), 'source file should be removed')
|
|
261
|
+
|
|
262
|
+
const files = (await lsFileInTrash(filename))
|
|
263
|
+
.map(file => path.basename(file))
|
|
264
|
+
.sort()
|
|
265
|
+
|
|
266
|
+
t.deepEqual(files, [filename, `${filename}.1`, `${filename}.2`])
|
|
267
|
+
t.is(await fs.readFile(existing0, 'utf8'), 'existing-0')
|
|
268
|
+
t.is(await fs.readFile(existing1, 'utf8'), 'existing-1')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
!is_rm(type) && !is_as(type) && !IS_MACOS && test(`linux .trashinfo stores an encoded absolute original path`, async t => {
|
|
272
|
+
const {
|
|
273
|
+
source_path,
|
|
274
|
+
trash_path,
|
|
275
|
+
createFile,
|
|
276
|
+
runRm,
|
|
277
|
+
pathExists
|
|
278
|
+
} = t.context
|
|
279
|
+
|
|
280
|
+
const filename = 'space name'
|
|
281
|
+
const filepath = await createFile({
|
|
282
|
+
name: filename
|
|
283
|
+
})
|
|
284
|
+
const canonicalFilepath = await fs.realpath(filepath)
|
|
285
|
+
|
|
286
|
+
const result = await runRm([`./${filename}`], {
|
|
287
|
+
cwd: source_path
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
assertEmptySuccess(t, result)
|
|
291
|
+
t.false(await pathExists(filepath), 'source file should be removed')
|
|
292
|
+
|
|
293
|
+
const infoPath = path.join(trash_path, 'info', `${filename}.trashinfo`)
|
|
294
|
+
const info = await fs.readFile(infoPath, 'utf8')
|
|
295
|
+
const expectedPath = encodeTrashInfoPath(canonicalFilepath)
|
|
296
|
+
|
|
297
|
+
t.true(info.includes(`[Trash Info]`))
|
|
298
|
+
t.true(info.includes(`Path=${expectedPath}`))
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
!is_rm(type) && !is_as(type) && IS_MACOS && test(`mac fallback duplicate naming matches Finder without extension`, async t => {
|
|
302
|
+
const {
|
|
303
|
+
root,
|
|
304
|
+
createDir,
|
|
305
|
+
createFile,
|
|
306
|
+
runRm,
|
|
307
|
+
lsFileInTrash
|
|
308
|
+
} = t.context
|
|
309
|
+
|
|
310
|
+
const mockBin = await createDir({
|
|
311
|
+
name: 'mock-bin-noext',
|
|
312
|
+
under: root
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const mockDate = await createFile({
|
|
316
|
+
name: 'date',
|
|
317
|
+
under: mockBin,
|
|
318
|
+
content: '#!/usr/bin/env bash\necho 12.34.56\n'
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
await fs.chmod(mockDate, 0o755)
|
|
322
|
+
|
|
323
|
+
const filename = `finder-noext-${uuid()}`
|
|
324
|
+
|
|
325
|
+
for (const content of ['1', '2', '3', '4']) {
|
|
326
|
+
const filepath = await createFile({
|
|
327
|
+
name: filename,
|
|
328
|
+
content
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const result = await runRm([filepath], {
|
|
332
|
+
env: {
|
|
333
|
+
PATH: `${mockBin}:${process.env.PATH}`
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
assertEmptySuccess(t, result)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const files = (await lsFileInTrash(filename))
|
|
341
|
+
.map(file => path.basename(file))
|
|
342
|
+
.sort((a, b) => a.length - b.length)
|
|
343
|
+
|
|
344
|
+
t.deepEqual(files, [
|
|
345
|
+
filename,
|
|
346
|
+
`${filename} 12.34.56`,
|
|
347
|
+
`${filename} 12.34.56X`,
|
|
348
|
+
`${filename} 12.34.56XX`
|
|
349
|
+
])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
!is_rm(type) && !is_as(type) && IS_MACOS && test(`mac fallback duplicate naming matches Finder with extensions`, async t => {
|
|
353
|
+
const {
|
|
354
|
+
root,
|
|
355
|
+
createDir,
|
|
356
|
+
createFile,
|
|
357
|
+
runRm,
|
|
358
|
+
lsFileInTrash
|
|
359
|
+
} = t.context
|
|
360
|
+
|
|
361
|
+
const mockBin = await createDir({
|
|
362
|
+
name: 'mock-bin-ext',
|
|
363
|
+
under: root
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const mockDate = await createFile({
|
|
367
|
+
name: 'date',
|
|
368
|
+
under: mockBin,
|
|
369
|
+
content: '#!/usr/bin/env bash\necho 12.34.56\n'
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
await fs.chmod(mockDate, 0o755)
|
|
373
|
+
|
|
374
|
+
const filename = `finder-ext-${uuid()}.jpg`
|
|
375
|
+
|
|
376
|
+
for (const content of ['1', '2', '3', '4']) {
|
|
377
|
+
const filepath = await createFile({
|
|
378
|
+
name: filename,
|
|
379
|
+
content
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
const result = await runRm([filepath], {
|
|
383
|
+
env: {
|
|
384
|
+
PATH: `${mockBin}:${process.env.PATH}`
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
assertEmptySuccess(t, result)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const files = (await lsFileInTrash(filename))
|
|
392
|
+
.map(file => path.basename(file))
|
|
393
|
+
.sort((a, b) => a.length - b.length)
|
|
394
|
+
|
|
395
|
+
const baseName = path.basename(filename, '.jpg')
|
|
396
|
+
|
|
397
|
+
t.deepEqual(files, [
|
|
398
|
+
filename,
|
|
399
|
+
`${baseName} 12.34.56.jpg`,
|
|
400
|
+
`${baseName} 12.34.56.jpgX`,
|
|
401
|
+
`${baseName} 12.34.56.jpgXX`
|
|
402
|
+
])
|
|
403
|
+
})
|
|
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
|
+
|
|
548
|
+
!is_rm(type) && !is_as(type) && test(`-I only prompts for more than three files`, async t => {
|
|
549
|
+
const {
|
|
550
|
+
createFile,
|
|
551
|
+
runRm,
|
|
552
|
+
pathExists
|
|
553
|
+
} = t.context
|
|
554
|
+
|
|
555
|
+
const files3 = await Promise.all(['a', 'b', 'c'].map(name => createFile({name})))
|
|
556
|
+
const result3 = await runRm(['-I', ...files3])
|
|
557
|
+
|
|
558
|
+
assertEmptySuccess(t, result3)
|
|
559
|
+
t.true(
|
|
560
|
+
(await Promise.all(files3.map(file => pathExists(file)))).every(exists => !exists),
|
|
561
|
+
'three files should be removed without a once-interactive prompt'
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
const files4 = await Promise.all(['d', 'e', 'f', 'g'].map(name => createFile({name})))
|
|
565
|
+
const result4 = await runRm(['-I', ...files4], {
|
|
566
|
+
input: ['n']
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
t.is(result4.code, 0, 'exit code should be 0 when declining the once-interactive prompt')
|
|
570
|
+
t.true(result4.stdout.includes('remove all arguments?'), 'stdout should contain the once-interactive prompt')
|
|
571
|
+
t.true(
|
|
572
|
+
(await Promise.all(files4.map(file => pathExists(file)))).every(Boolean),
|
|
573
|
+
'files should remain after declining the once-interactive prompt'
|
|
574
|
+
)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
!is_rm(type) && !is_as(type) && IS_MACOS && test(`declining trash directory creation aborts removal`, async t => {
|
|
578
|
+
const {
|
|
579
|
+
root,
|
|
580
|
+
createFile,
|
|
581
|
+
runRm,
|
|
582
|
+
pathExists
|
|
583
|
+
} = t.context
|
|
584
|
+
|
|
585
|
+
const filepath = await createFile({
|
|
586
|
+
name: 'needs-trash'
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
const trashPath = path.join(root, 'missing-trash')
|
|
590
|
+
const result = await runRm([filepath], {
|
|
591
|
+
input: ['no'],
|
|
592
|
+
env: {
|
|
593
|
+
SAFE_RM_TRASH: trashPath
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
t.is(result.code, 1, 'exit code should be 1 when declining trash creation')
|
|
598
|
+
t.true(result.stdout.includes(`Directory "${trashPath}" does not exist`))
|
|
599
|
+
t.true(result.stdout.includes('Canceled!'))
|
|
600
|
+
t.true(await pathExists(filepath), 'file should remain in place')
|
|
601
|
+
t.false(await pathExists(trashPath), 'trash directory should not be created')
|
|
602
|
+
})
|
|
603
|
+
|
|
196
604
|
test(`#22 exit code with -f option`, async t => {
|
|
197
605
|
const {
|
|
198
606
|
source_path,
|
package/test/helper.js
CHANGED
|
@@ -12,6 +12,8 @@ const IS_MACOS = process.platform === 'darwin'
|
|
|
12
12
|
// For linux mock testing
|
|
13
13
|
&& !process.env.SAFE_RM_DEBUG_LINUX
|
|
14
14
|
|
|
15
|
+
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
16
|
+
|
|
15
17
|
const generateContextMethods = (
|
|
16
18
|
rm_command,
|
|
17
19
|
rm_command_env
|
|
@@ -83,7 +85,7 @@ const generateContextMethods = (
|
|
|
83
85
|
child.stdout.on('data', data => {
|
|
84
86
|
stdout += data.toString()
|
|
85
87
|
|
|
86
|
-
if (
|
|
88
|
+
if (/[?:]\s*$/.test(stdout)) {
|
|
87
89
|
if (!child.stdin) {
|
|
88
90
|
reject(new Error('Child process does not support stdin'))
|
|
89
91
|
return
|
|
@@ -144,9 +146,12 @@ const generateContextMethods = (
|
|
|
144
146
|
const _filename = path.basename(filepath)
|
|
145
147
|
const ext = path.extname(_filename)
|
|
146
148
|
const filename = path.basename(_filename, ext)
|
|
149
|
+
const pattern = ext
|
|
150
|
+
? new RegExp(`^${escapeRegExp(filename)}(?: \\d{2}\\.\\d{2}\\.\\d{2})?${escapeRegExp(ext)}X*$`)
|
|
151
|
+
: new RegExp(`^${escapeRegExp(filename)}(?: \\d{2}\\.\\d{2}\\.\\d{2}X*)?$`)
|
|
147
152
|
|
|
148
153
|
const filtered = files.filter(
|
|
149
|
-
f =>
|
|
154
|
+
f => pattern.test(f)
|
|
150
155
|
).map(f => path.join(trash_path, f))
|
|
151
156
|
|
|
152
157
|
return filtered
|