safe-rm 3.1.4 → 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
@@ -498,6 +498,43 @@ get_absolute_path(){
498
498
  }
499
499
 
500
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"/* ]]
535
+ }
536
+
537
+
501
538
  # Returns
502
539
  # - 0: the target is protected
503
540
  # - 1: the target is not protected
@@ -559,16 +596,22 @@ check_mac_trash_path(){
559
596
  local ext=$2
560
597
  local full_path="$path$ext"
561
598
 
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
599
+ if [[ ! -e "$full_path" ]]; then
570
600
  _mac_trash_path_ret=$full_path
601
+ return
571
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
572
615
  }
573
616
 
574
617
 
@@ -651,16 +694,35 @@ check_linux_trash_base(){
651
694
 
652
695
  local max_n=0
653
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
654
706
 
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
707
+ for file in "${list[@]}"; do
708
+ name=$(basename -- "$file")
709
+
710
+ if [[ "$name" != "$base".* ]]; then
711
+ continue
662
712
  fi
663
- done < <(find "$trash" -maxdepth 1)
713
+
714
+ suffix=${name#"$base".}
715
+
716
+ if [[ ! "$suffix" =~ ^[0-9]+$ ]]; then
717
+ continue
718
+ fi
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
664
726
 
665
727
  (( max_n += 1 ))
666
728
 
@@ -674,6 +736,12 @@ check_linux_trash_base(){
674
736
  # trash a file or dir directly for linux
675
737
  # - move the target into
676
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
+
677
745
  check_target_to_move "$1"
678
746
  local move=$_to_move
679
747
  local base=$(basename -- "$move")
@@ -700,7 +768,7 @@ linux_trash(){
700
768
  local trash_time=$(date +%Y-%m-%dT%H:%M:%S)
701
769
  cat > "$info_path" <<EOF
702
770
  [Trash Info]
703
- Path=$move
771
+ Path=$trashinfo_path_value
704
772
  DeletionDate=$trash_time
705
773
  EOF
706
774
  local info_status=$?
@@ -739,7 +807,7 @@ list_files(){
739
807
  debug "$LINENO: ${#FILE_NAME[@]} files or directory to process: ${FILE_NAME[@]}"
740
808
 
741
809
  # 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
810
+ if [[ (${#FILE_NAME[@]} > 3 || $OPT_RECURSIVE == 1) && $OPT_INTERACTIVE_ONCE == 1 ]]; then
743
811
  echo -n "$COMMAND: remove all arguments? "
744
812
  read answer
745
813
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safe-rm",
3
- "version": "3.1.4",
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,
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