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 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 $anwser ]]; then
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 [[ "$target" == "$SAFE_RM_TRASH"* ]]; then
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
- dir=$(cd "$(dirname -- "$1")" && pwd)
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
- printf '%s/%s\n' "$dir" "$base"
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
- # if already in the trash
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
- while IFS= read -r file; do
656
- if [[ $file =~ ${base}\.([0-9]+)$ ]]; then
657
- # Remove leading zeros and make sure the number is in base 10
658
- num=$((10#${BASH_REMATCH[1]}))
659
- if ((num > max_n)); then
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
- done < <(find "$trash" -maxdepth 1)
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=$move
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[@]} > 2 || $OPT_RECURSIVE == 1) && $OPT_INTERACTIVE_ONCE == 1 ]]; then
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-rm",
3
- "version": "3.1.4",
3
+ "version": "3.2.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
@@ -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
- // /path/to/foo[.jpg]
115
- // /path/to/foo 12.58.23[.jpg]
116
- // /path/to/foo 12.58.23 12.58.23[.jpg]
117
- const [f1, f2, f3] = files
118
-
119
- const [fb1, fb2, fb3] = [f1, f2, f3].map(
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
- const [fbs1, fbs2, fbs3] = [fb1, fb2, fb3].map(f => f.split(' '))
128
-
129
- const time = fbs2[1]
130
-
131
- t.true(files.every(f => f.endsWith(ext)), 'should have the same ext')
132
-
133
- t.is(fb1, filename)
134
- t.is(fbs1[0], filename)
135
- t.is(fbs2[0], filename)
136
- t.is(fbs3[0], filename)
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 (/\?\s*$/.test(stdout)) {
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 => f.endsWith(ext) && f.startsWith(filename)
154
+ f => pattern.test(f)
150
155
  ).map(f => path.join(trash_path, f))
151
156
 
152
157
  return filtered