safe-rm 3.1.3 → 3.1.5

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
@@ -330,7 +330,7 @@ if [[ ! -e $SAFE_RM_TRASH ]]; then
330
330
  echo -n "(yes/no): "
331
331
 
332
332
  read answer
333
- if [[ $answer == "yes" || ! -n $anwser ]]; then
333
+ if [[ $answer == "yes" || ! -n $answer ]]; then
334
334
  mkdir -p "$SAFE_RM_TRASH"
335
335
  else
336
336
  echo "Canceled!"
@@ -455,7 +455,7 @@ trash(){
455
455
  local target=$1
456
456
 
457
457
  if [[ -n $SAFE_RM_PERM_DEL_FILES_IN_TRASH ]]; then
458
- if [[ "$target" == "$SAFE_RM_TRASH"* ]]; then
458
+ if is_in_trash "$target"; then
459
459
  # If the target is already in the trash, delete it permanently
460
460
  /bin/rm -rf "$target"
461
461
  else
@@ -490,7 +490,48 @@ do_trash(){
490
490
 
491
491
 
492
492
  get_absolute_path(){
493
- 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"
498
+ }
499
+
500
+
501
+ encode_trashinfo_path(){
502
+ local path=$1
503
+ local out=
504
+ local i
505
+ local char
506
+ local hex
507
+ local LC_ALL=C
508
+
509
+ for ((i = 0; i < ${#path}; i += 1)); do
510
+ char=${path:i:1}
511
+
512
+ case "$char" in
513
+ [a-zA-Z0-9._~/-])
514
+ out="$out$char"
515
+ ;;
516
+ *)
517
+ hex=$(printf '%s' "$char" | od -An -tx1 | tr -d ' \n' | tr '[:lower:]' '[:upper:]')
518
+ out="$out%$hex"
519
+ ;;
520
+ esac
521
+ done
522
+
523
+ printf '%s\n' "$out"
524
+ }
525
+
526
+
527
+ is_in_trash(){
528
+ local target_abs
529
+ local trash_abs
530
+
531
+ target_abs=$(get_absolute_path "$1") || return 1
532
+ trash_abs=$(cd "$SAFE_RM_TRASH" && pwd) || return 1
533
+
534
+ [[ "$target_abs" == "$trash_abs" || "$target_abs" == "$trash_abs"/* ]]
494
535
  }
495
536
 
496
537
 
@@ -555,16 +596,22 @@ check_mac_trash_path(){
555
596
  local ext=$2
556
597
  local full_path="$path$ext"
557
598
 
558
- # if already in the trash
559
- if [[ -e "$full_path" ]]; then
560
- debug "$LINENO: $full_path already exists"
561
-
562
- # renew $_short_time_ret
563
- short_time
564
- check_mac_trash_path "$path $_short_time_ret" "$ext"
565
- else
599
+ if [[ ! -e "$full_path" ]]; then
566
600
  _mac_trash_path_ret=$full_path
601
+ return
567
602
  fi
603
+
604
+ debug "$LINENO: $full_path already exists"
605
+
606
+ short_time
607
+ full_path="$path $_short_time_ret$ext"
608
+
609
+ while [[ -e "$full_path" ]]; do
610
+ debug "$LINENO: $full_path already exists"
611
+ full_path="${full_path}X"
612
+ done
613
+
614
+ _mac_trash_path_ret=$full_path
568
615
  }
569
616
 
570
617
 
@@ -601,7 +648,7 @@ check_target_to_move(){
601
648
  mac_trash(){
602
649
  check_target_to_move "$1"
603
650
  local move=$_to_move
604
- local base=$(basename "$move")
651
+ local base=$(basename -- "$move")
605
652
 
606
653
  # foo.jpg => "foo" + ".jpg"
607
654
  # foo => "foo" + ""
@@ -623,7 +670,7 @@ mac_trash(){
623
670
  [[ "$OPT_VERBOSE" == 1 ]] && list_files "$1"
624
671
 
625
672
  debug "$LINENO: mv $move to $trash_path"
626
- mv "$move" "$trash_path"
673
+ mv -- "$move" "$trash_path"
627
674
  local status=$?
628
675
 
629
676
  [[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
@@ -647,16 +694,35 @@ check_linux_trash_base(){
647
694
 
648
695
  local max_n=0
649
696
  local num=
697
+ local restore_nullglob=$(shopt -p nullglob)
698
+ local restore_dotglob=$(shopt -p dotglob)
699
+ shopt -s nullglob dotglob
700
+ local list=("$trash"/*)
701
+ eval "$restore_nullglob"
702
+ eval "$restore_dotglob"
703
+ local file
704
+ local name
705
+ local suffix
706
+
707
+ for file in "${list[@]}"; do
708
+ name=$(basename -- "$file")
709
+
710
+ if [[ "$name" != "$base".* ]]; then
711
+ continue
712
+ fi
713
+
714
+ suffix=${name#"$base".}
650
715
 
651
- while IFS= read -r file; do
652
- if [[ $file =~ ${base}\.([0-9]+)$ ]]; then
653
- # Remove leading zeros and make sure the number is in base 10
654
- num=$((10#${BASH_REMATCH[1]}))
655
- if ((num > max_n)); then
656
- max_n=$num
657
- fi
716
+ if [[ ! "$suffix" =~ ^[0-9]+$ ]]; then
717
+ continue
658
718
  fi
659
- done < <(find "$trash" -maxdepth 1)
719
+
720
+ # Remove leading zeros and make sure the number is in base 10
721
+ num=$((10#$suffix))
722
+ if ((num > max_n)); then
723
+ max_n=$num
724
+ fi
725
+ done
660
726
 
661
727
  (( max_n += 1 ))
662
728
 
@@ -670,9 +736,15 @@ check_linux_trash_base(){
670
736
  # trash a file or dir directly for linux
671
737
  # - move the target into
672
738
  linux_trash(){
739
+ local original_path
740
+ local trashinfo_path_value
741
+
742
+ original_path=$(get_absolute_path "$1") || return 1
743
+ trashinfo_path_value=$(encode_trashinfo_path "$original_path") || return 1
744
+
673
745
  check_target_to_move "$1"
674
746
  local move=$_to_move
675
- local base=$(basename "$move")
747
+ local base=$(basename -- "$move")
676
748
 
677
749
  base=$(check_linux_trash_base "$base")
678
750
 
@@ -682,7 +754,7 @@ linux_trash(){
682
754
 
683
755
  # Move the target into the trash
684
756
  debug "$LINENO: mv $move to $trash_path"
685
- mv "$move" "$trash_path"
757
+ mv -- "$move" "$trash_path"
686
758
  local move_status=$?
687
759
 
688
760
  if [[ $move_status -ne 0 ]]; then
@@ -696,7 +768,7 @@ linux_trash(){
696
768
  local trash_time=$(date +%Y-%m-%dT%H:%M:%S)
697
769
  cat > "$info_path" <<EOF
698
770
  [Trash Info]
699
- Path=$move
771
+ Path=$trashinfo_path_value
700
772
  DeletionDate=$trash_time
701
773
  EOF
702
774
  local info_status=$?
@@ -735,7 +807,7 @@ list_files(){
735
807
  debug "$LINENO: ${#FILE_NAME[@]} files or directory to process: ${FILE_NAME[@]}"
736
808
 
737
809
  # test remove interactive_once: ask for 3 or more files or with recursive option
738
- if [[ (${#FILE_NAME[@]} > 2 || $OPT_RECURSIVE == 1) && $OPT_INTERACTIVE_ONCE == 1 ]]; then
810
+ if [[ (${#FILE_NAME[@]} > 3 || $OPT_RECURSIVE == 1) && $OPT_INTERACTIVE_ONCE == 1 ]]; then
739
811
  echo -n "$COMMAND: remove all arguments? "
740
812
  read answer
741
813
 
@@ -763,28 +835,20 @@ for file in "${FILE_NAME[@]}"; do
763
835
  fi
764
836
 
765
837
  # the same check also apply on /. /..
766
- if [[ $(basename "$file") == "." || $(basename "$file") == ".." ]]; then
838
+ if [[ $(basename -- "$file") == "." || $(basename -- "$file") == ".." ]]; then
767
839
  error "$COMMAND: \".\" and \"..\" may not be removed"
768
840
  EXIT_CODE=1
769
841
  continue
770
842
  fi
771
843
 
772
- # deal with wildcard and also, redirect error output
773
- ls_result=$(ls -d "$file" 2> /dev/null)
844
+ if [[ -e "$file" || -L "$file" ]]; then
845
+ remove "$file"
846
+ status=$?
847
+ debug "$LINENO: remove returned status: $status"
774
848
 
775
- # debug
776
- debug "$LINENO: ls_result: $ls_result"
777
-
778
- if [[ -n "$ls_result" ]]; then
779
- for file in "$ls_result"; do
780
- remove "$file"
781
- status=$?
782
- debug "$LINENO: remove returned status: $status"
783
-
784
- if [[ ! $status == 0 ]]; then
785
- EXIT_CODE=1
786
- fi
787
- done
849
+ if [[ ! $status == 0 ]]; then
850
+ EXIT_CODE=1
851
+ fi
788
852
  elif [[ -z "$OPT_FORCE" ]]; then
789
853
  error "$COMMAND: $file: No such file or directory" >&2
790
854
  EXIT_CODE=1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-rm",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
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,276 @@ 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) && !is_as(type) && test(`-I only prompts for more than three files`, async t => {
406
+ const {
407
+ createFile,
408
+ runRm,
409
+ pathExists
410
+ } = t.context
411
+
412
+ const files3 = await Promise.all(['a', 'b', 'c'].map(name => createFile({name})))
413
+ const result3 = await runRm(['-I', ...files3])
414
+
415
+ assertEmptySuccess(t, result3)
416
+ t.true(
417
+ (await Promise.all(files3.map(file => pathExists(file)))).every(exists => !exists),
418
+ 'three files should be removed without a once-interactive prompt'
419
+ )
420
+
421
+ const files4 = await Promise.all(['d', 'e', 'f', 'g'].map(name => createFile({name})))
422
+ const result4 = await runRm(['-I', ...files4], {
423
+ input: ['n']
424
+ })
425
+
426
+ t.is(result4.code, 0, 'exit code should be 0 when declining the once-interactive prompt')
427
+ t.true(result4.stdout.includes('remove all arguments?'), 'stdout should contain the once-interactive prompt')
428
+ t.true(
429
+ (await Promise.all(files4.map(file => pathExists(file)))).every(Boolean),
430
+ 'files should remain after declining the once-interactive prompt'
431
+ )
432
+ })
433
+
434
+ !is_rm(type) && !is_as(type) && IS_MACOS && test(`declining trash directory creation aborts removal`, async t => {
435
+ const {
436
+ root,
437
+ createFile,
438
+ runRm,
439
+ pathExists
440
+ } = t.context
441
+
442
+ const filepath = await createFile({
443
+ name: 'needs-trash'
444
+ })
445
+
446
+ const trashPath = path.join(root, 'missing-trash')
447
+ const result = await runRm([filepath], {
448
+ input: ['no'],
449
+ env: {
450
+ SAFE_RM_TRASH: trashPath
451
+ }
452
+ })
453
+
454
+ t.is(result.code, 1, 'exit code should be 1 when declining trash creation')
455
+ t.true(result.stdout.includes(`Directory "${trashPath}" does not exist`))
456
+ t.true(result.stdout.includes('Canceled!'))
457
+ t.true(await pathExists(filepath), 'file should remain in place')
458
+ t.false(await pathExists(trashPath), 'trash directory should not be created')
459
+ })
460
+
196
461
  test(`#22 exit code with -f option`, async t => {
197
462
  const {
198
463
  source_path,
@@ -447,4 +712,124 @@ fi
447
712
  t.false(await pathExists(link), 'symlink should be removed')
448
713
  t.true(await pathExists(target), 'target file should remain')
449
714
  })
715
+
716
+ !is_as(type) && test(`removes a dangling symlink`, async t => {
717
+ const {
718
+ source_path,
719
+ runRm
720
+ } = t.context
721
+
722
+ const missingTarget = path.join(source_path, 'missing-target')
723
+ const link = path.join(source_path, 'dangling_sym')
724
+ await fs.symlink(missingTarget, link)
725
+
726
+ const before = await fs.lstat(link)
727
+ t.true(before.isSymbolicLink(), 'should create symlink before remove')
728
+
729
+ const result = await runRm([link])
730
+
731
+ t.is(result.code, 0, 'exit code should be 0')
732
+ await t.throwsAsync(async () => fs.lstat(link), {
733
+ code: 'ENOENT'
734
+ })
735
+ })
736
+
737
+ !is_as(type) && test(`-r should not dereference a directory symlink`, async t => {
738
+ const {
739
+ createDir,
740
+ createFile,
741
+ runRm,
742
+ pathExists
743
+ } = t.context
744
+
745
+ const realDir = await createDir({
746
+ name: 'real-dir'
747
+ })
748
+
749
+ const keep = await createFile({
750
+ name: 'keep',
751
+ under: realDir
752
+ })
753
+
754
+ const link = path.join(path.dirname(realDir), 'real-dir-sym')
755
+ await fs.symlink(realDir, link)
756
+
757
+ const result = await runRm(['-r', link])
758
+
759
+ t.is(result.code, 0, 'exit code should be 0')
760
+ await t.throwsAsync(async () => fs.lstat(link), {
761
+ code: 'ENOENT'
762
+ })
763
+ t.true(await pathExists(realDir), 'target directory should remain')
764
+ t.true(await pathExists(keep), 'target content should remain')
765
+ })
766
+
767
+ !is_as(type) && test(`-ri should not dereference a directory symlink`, async t => {
768
+ const {
769
+ createDir,
770
+ createFile,
771
+ runRm,
772
+ pathExists
773
+ } = t.context
774
+
775
+ const realDir = await createDir({
776
+ name: 'real-dir-i'
777
+ })
778
+
779
+ const keep = await createFile({
780
+ name: 'keep-i',
781
+ under: realDir
782
+ })
783
+
784
+ const link = path.join(path.dirname(realDir), 'real-dir-sym-i')
785
+ await fs.symlink(realDir, link)
786
+
787
+ const result = await runRm(['-ri', link], {
788
+ input: ['y']
789
+ })
790
+
791
+ t.is(result.code, 0, 'exit code should be 0')
792
+ await t.throwsAsync(async () => fs.lstat(link), {
793
+ code: 'ENOENT'
794
+ })
795
+ t.true(await pathExists(realDir), 'target directory should remain')
796
+ t.true(await pathExists(keep), 'target content should remain')
797
+ })
798
+
799
+ !is_as(type) && test(`removes a file prefixed with "-" after --`, async t => {
800
+ const {
801
+ source_path,
802
+ createFile,
803
+ runRm,
804
+ pathExists
805
+ } = t.context
806
+
807
+ const name = '-dash-file'
808
+ await createFile({name})
809
+
810
+ const result = await runRm(['--', name], {
811
+ cwd: source_path
812
+ })
813
+
814
+ t.is(result.code, 0, 'exit code should be 0')
815
+ t.false(await pathExists(name), 'file should be removed')
816
+ })
817
+
818
+ !is_as(type) && test(`removes a file with newline in filename`, async t => {
819
+ const {
820
+ createFile,
821
+ runRm,
822
+ pathExists
823
+ } = t.context
824
+
825
+ const filename = 'line1\nline2'
826
+ const filepath = await createFile({
827
+ name: filename
828
+ })
829
+
830
+ const result = await runRm([filepath])
831
+
832
+ t.is(result.code, 0, 'exit code should be 0')
833
+ t.false(await pathExists(filepath), 'file should be removed')
834
+ })
450
835
  }
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
@@ -60,7 +62,8 @@ const generateContextMethods = (
60
62
  function runRm (args, {
61
63
  input = [],
62
64
  command = rm_command,
63
- env: arg_env = {}
65
+ env: arg_env = {},
66
+ cwd
64
67
  } = {}) {
65
68
  return new Promise((resolve, reject) => {
66
69
  const env = {
@@ -73,7 +76,8 @@ const generateContextMethods = (
73
76
  }
74
77
 
75
78
  const child = spawn(command, args, {
76
- env
79
+ env,
80
+ cwd
77
81
  })
78
82
  let stdout = ''
79
83
  let stderr = ''
@@ -81,7 +85,7 @@ const generateContextMethods = (
81
85
  child.stdout.on('data', data => {
82
86
  stdout += data.toString()
83
87
 
84
- if (/\?\s*$/.test(stdout)) {
88
+ if (/[?:]\s*$/.test(stdout)) {
85
89
  if (!child.stdin) {
86
90
  reject(new Error('Child process does not support stdin'))
87
91
  return
@@ -142,9 +146,12 @@ const generateContextMethods = (
142
146
  const _filename = path.basename(filepath)
143
147
  const ext = path.extname(_filename)
144
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*)?$`)
145
152
 
146
153
  const filtered = files.filter(
147
- f => f.endsWith(ext) && f.startsWith(filename)
154
+ f => pattern.test(f)
148
155
  ).map(f => path.join(trash_path, f))
149
156
 
150
157
  return filtered