safe-rm 3.1.1 → 3.1.3
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 +55 -21
- package/package.json +4 -2
- package/test/cases.js +175 -0
- package/test/helper.js +25 -12
- package/.travis.yml +0 -4
package/bin/rm.sh
CHANGED
|
@@ -43,7 +43,7 @@ debug(){
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
error(){
|
|
46
|
-
echo $@ >&2
|
|
46
|
+
echo "$@" >&2
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
|
|
@@ -389,6 +389,8 @@ remove(){
|
|
|
389
389
|
read answer
|
|
390
390
|
if [[ ${answer:0:1} =~ [yY] ]]; then
|
|
391
391
|
[[ $(ls -A "$file") ]] && {
|
|
392
|
+
debug "$LINENO: $file: Directory not empty: $(ls -A "$file")"
|
|
393
|
+
|
|
392
394
|
echo "$COMMAND: $file: Directory not empty"
|
|
393
395
|
|
|
394
396
|
return 1
|
|
@@ -427,15 +429,24 @@ remove(){
|
|
|
427
429
|
|
|
428
430
|
|
|
429
431
|
recursive_remove(){
|
|
432
|
+
local dir=$1
|
|
430
433
|
local path
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
#
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
434
|
+
local restore_nullglob=$(shopt -p nullglob)
|
|
435
|
+
local restore_dotglob=$(shopt -p dotglob)
|
|
436
|
+
|
|
437
|
+
# Avoid `ls -A` + unquoted `for` iteration, which splits names by IFS
|
|
438
|
+
# and breaks paths containing spaces (e.g. "a b" -> "a" + "b").
|
|
439
|
+
# Use glob expansion with arrays to keep each entry as one element.
|
|
440
|
+
# dotglob includes hidden files, nullglob avoids a literal "$dir/*" token.
|
|
441
|
+
shopt -s nullglob dotglob
|
|
442
|
+
local list=("$dir"/*)
|
|
443
|
+
eval "$restore_nullglob"
|
|
444
|
+
eval "$restore_dotglob"
|
|
445
|
+
|
|
446
|
+
for path in "${list[@]}"; do
|
|
447
|
+
debug "$LINENO: recursively remove: $path"
|
|
448
|
+
|
|
449
|
+
remove "$path"
|
|
439
450
|
done
|
|
440
451
|
}
|
|
441
452
|
|
|
@@ -517,12 +528,19 @@ applescript_trash(){
|
|
|
517
528
|
|
|
518
529
|
[[ "$OPT_VERBOSE" == 1 ]] && list_files "$target"
|
|
519
530
|
|
|
531
|
+
# #47: Finder alias resolves symlinks to their targets.
|
|
532
|
+
# For symbolic links, fallback to `mv`-based trash to remove the link itself.
|
|
533
|
+
if [[ -L "$target" ]]; then
|
|
534
|
+
debug "$LINENO: symlink detected, fallback to mac_trash: $target"
|
|
535
|
+
mac_trash "$target"
|
|
536
|
+
return $?
|
|
537
|
+
fi
|
|
538
|
+
|
|
520
539
|
debug "$LINENO: osascript delete $target"
|
|
521
540
|
|
|
522
541
|
osascript -e "tell application \"Finder\" to delete (POSIX file \"$target\" as alias)" &> /dev/null
|
|
523
542
|
|
|
524
|
-
|
|
525
|
-
return 0
|
|
543
|
+
return $?
|
|
526
544
|
}
|
|
527
545
|
|
|
528
546
|
|
|
@@ -606,11 +624,12 @@ mac_trash(){
|
|
|
606
624
|
|
|
607
625
|
debug "$LINENO: mv $move to $trash_path"
|
|
608
626
|
mv "$move" "$trash_path"
|
|
627
|
+
local status=$?
|
|
609
628
|
|
|
610
|
-
[[ "$_traveled" == 1 ]] && cd $__DIRNAME &> /dev/null
|
|
629
|
+
[[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
|
|
611
630
|
|
|
612
|
-
#
|
|
613
|
-
return
|
|
631
|
+
# Propagate mv status; otherwise callers may treat move failures as success.
|
|
632
|
+
return $status
|
|
614
633
|
}
|
|
615
634
|
|
|
616
635
|
|
|
@@ -630,7 +649,7 @@ check_linux_trash_base(){
|
|
|
630
649
|
local num=
|
|
631
650
|
|
|
632
651
|
while IFS= read -r file; do
|
|
633
|
-
if [[ $file =~ ${
|
|
652
|
+
if [[ $file =~ ${base}\.([0-9]+)$ ]]; then
|
|
634
653
|
# Remove leading zeros and make sure the number is in base 10
|
|
635
654
|
num=$((10#${BASH_REMATCH[1]}))
|
|
636
655
|
if ((num > max_n)); then
|
|
@@ -664,6 +683,13 @@ linux_trash(){
|
|
|
664
683
|
# Move the target into the trash
|
|
665
684
|
debug "$LINENO: mv $move to $trash_path"
|
|
666
685
|
mv "$move" "$trash_path"
|
|
686
|
+
local move_status=$?
|
|
687
|
+
|
|
688
|
+
if [[ $move_status -ne 0 ]]; then
|
|
689
|
+
# Keep failure visible to remove()/EXIT_CODE and skip writing .trashinfo.
|
|
690
|
+
[[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
|
|
691
|
+
return $move_status
|
|
692
|
+
fi
|
|
667
693
|
|
|
668
694
|
# Save linux trash info
|
|
669
695
|
local info_path="$SAFE_RM_TRASH/info/$base.trashinfo"
|
|
@@ -673,10 +699,11 @@ linux_trash(){
|
|
|
673
699
|
Path=$move
|
|
674
700
|
DeletionDate=$trash_time
|
|
675
701
|
EOF
|
|
702
|
+
local info_status=$?
|
|
676
703
|
|
|
677
|
-
[[ "$_traveled" == 1 ]] && cd $__DIRNAME &> /dev/null
|
|
704
|
+
[[ "$_traveled" == 1 ]] && cd "$__DIRNAME" &> /dev/null
|
|
678
705
|
|
|
679
|
-
return
|
|
706
|
+
return $info_status
|
|
680
707
|
}
|
|
681
708
|
|
|
682
709
|
|
|
@@ -685,15 +712,22 @@ EOF
|
|
|
685
712
|
# 'coz `find` act a inward searching, unlike rm -v
|
|
686
713
|
list_files(){
|
|
687
714
|
if [[ -d "$1" ]]; then
|
|
688
|
-
local
|
|
715
|
+
local restore_nullglob=$(shopt -p nullglob)
|
|
716
|
+
local restore_dotglob=$(shopt -p dotglob)
|
|
717
|
+
# Keep traversal behavior aligned with recursive_remove(): no word splitting
|
|
718
|
+
# for names with spaces, and include dotfiles for rm -v output.
|
|
719
|
+
shopt -s nullglob dotglob
|
|
720
|
+
local list=("$1"/*)
|
|
721
|
+
eval "$restore_nullglob"
|
|
722
|
+
eval "$restore_dotglob"
|
|
689
723
|
local f
|
|
690
724
|
|
|
691
|
-
|
|
692
|
-
list_files "$
|
|
725
|
+
for f in "${list[@]}"; do
|
|
726
|
+
list_files "$f"
|
|
693
727
|
done
|
|
694
728
|
fi
|
|
695
729
|
|
|
696
|
-
echo $1
|
|
730
|
+
echo "$1"
|
|
697
731
|
}
|
|
698
732
|
|
|
699
733
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safe-rm",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "ava --timeout=10s --verbose",
|
|
9
|
+
"test:rm": "ava --timeout=10s --verbose test/safe-rm.test.js",
|
|
10
|
+
"test:as": "ava --timeout=10s --verbose test/safe-rm-as.test.js",
|
|
11
|
+
"test": "npm run test:rm && npm run test:as",
|
|
10
12
|
"test:dev": "NODE_DEBUG=safe-rm npm run test",
|
|
11
13
|
"test:debug": "SAFE_RM_DEBUG=1 npm run test:dev",
|
|
12
14
|
"test:mock-linux": "SAFE_RM_DEBUG_LINUX=1 npm run test",
|
package/test/cases.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const path = require('path')
|
|
2
|
+
const fs = require('fs').promises
|
|
2
3
|
const {v4: uuid} = require('uuid')
|
|
3
4
|
const delay = require('delay')
|
|
4
5
|
|
|
@@ -272,4 +273,178 @@ module.exports = (
|
|
|
272
273
|
t.true(result.stderr.includes('protected'), 'stderr should include "protected"')
|
|
273
274
|
t.true(await pathExists(filepath), 'file should not be removed')
|
|
274
275
|
})
|
|
276
|
+
|
|
277
|
+
!is_as(type) && test(`#44: recursively and interactively`, async t => {
|
|
278
|
+
const {
|
|
279
|
+
createDir,
|
|
280
|
+
createFile,
|
|
281
|
+
runRm,
|
|
282
|
+
pathExists
|
|
283
|
+
} = t.context
|
|
284
|
+
|
|
285
|
+
const name = 'foo'
|
|
286
|
+
|
|
287
|
+
const dir = await createDir({
|
|
288
|
+
name
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const fileA = await createFile({
|
|
292
|
+
name: 'a',
|
|
293
|
+
under: dir
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const fileB = await createFile({
|
|
297
|
+
name: 'b',
|
|
298
|
+
under: dir
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const result = await runRm(['-ri', dir], {
|
|
302
|
+
input: ['y', 'y', 'y', 'y']
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
t.is(result.stderr, '', 'stderr should be empty')
|
|
306
|
+
t.is(result.code, 0, 'exit code should be 0')
|
|
307
|
+
t.is(
|
|
308
|
+
result.stdout,
|
|
309
|
+
`examine files in directory ${dir}? y
|
|
310
|
+
remove ${fileA}? y
|
|
311
|
+
remove ${fileB}? y
|
|
312
|
+
remove ${dir}? y
|
|
313
|
+
`,
|
|
314
|
+
'stdout does not match'
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
t.false(await pathExists(dir), 'directory should be removed')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
!is_as(type) && test(`recursively and interactively with spaces`, async t => {
|
|
321
|
+
// Regression guard: recursive interactive remove must keep "a b"
|
|
322
|
+
// as one path and still remove the directory successfully.
|
|
323
|
+
const {
|
|
324
|
+
createDir,
|
|
325
|
+
createFile,
|
|
326
|
+
runRm,
|
|
327
|
+
pathExists
|
|
328
|
+
} = t.context
|
|
329
|
+
|
|
330
|
+
const dir = await createDir({
|
|
331
|
+
name: 'foo'
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const fileA = await createFile({
|
|
335
|
+
name: 'a b',
|
|
336
|
+
under: dir
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const fileB = await createFile({
|
|
340
|
+
name: 'c',
|
|
341
|
+
under: dir
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const result = await runRm(['-ri', dir], {
|
|
345
|
+
input: ['y', 'y', 'y', 'y']
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
t.is(result.stderr, '', 'stderr should be empty')
|
|
349
|
+
t.is(result.code, 0, 'exit code should be 0')
|
|
350
|
+
t.is(
|
|
351
|
+
result.stdout,
|
|
352
|
+
`examine files in directory ${dir}? y
|
|
353
|
+
remove ${fileA}? y
|
|
354
|
+
remove ${fileB}? y
|
|
355
|
+
remove ${dir}? y
|
|
356
|
+
`,
|
|
357
|
+
'stdout does not match'
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
t.false(await pathExists(dir), 'directory should be removed')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
!is_as(type) && test(`#47: removes symlink itself instead of target`, async t => {
|
|
364
|
+
const {
|
|
365
|
+
createFile,
|
|
366
|
+
runRm,
|
|
367
|
+
pathExists
|
|
368
|
+
} = t.context
|
|
369
|
+
|
|
370
|
+
const target = await createFile({
|
|
371
|
+
name: 'file1'
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
const link = path.join(path.dirname(target), 'file1_sym')
|
|
375
|
+
await fs.symlink(target, link)
|
|
376
|
+
|
|
377
|
+
const result = await runRm([link])
|
|
378
|
+
|
|
379
|
+
t.is(result.code, 0, 'exit code should be 0')
|
|
380
|
+
t.false(await pathExists(link), 'symlink should be removed')
|
|
381
|
+
t.true(await pathExists(target), 'target file should remain')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
!is_as(type) && test(`#47: applescript path should also remove symlink itself`, async t => {
|
|
385
|
+
const {
|
|
386
|
+
root,
|
|
387
|
+
createDir,
|
|
388
|
+
createFile,
|
|
389
|
+
runRm,
|
|
390
|
+
pathExists
|
|
391
|
+
} = t.context
|
|
392
|
+
|
|
393
|
+
const home = await createDir({
|
|
394
|
+
name: 'home',
|
|
395
|
+
under: root
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
await createDir({
|
|
399
|
+
name: '.Trash',
|
|
400
|
+
under: home
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const mockBin = await createDir({
|
|
404
|
+
name: 'mock-bin',
|
|
405
|
+
under: root
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// Simulate Finder alias behavior: if safe-rm calls osascript with a symlink,
|
|
409
|
+
// this mock deletes the resolved target. safe-rm should bypass this for symlinks.
|
|
410
|
+
const mockOsa = await createFile({
|
|
411
|
+
name: 'osascript',
|
|
412
|
+
under: mockBin,
|
|
413
|
+
content: `#!/usr/bin/env bash
|
|
414
|
+
expr=$2
|
|
415
|
+
target=$(printf '%s' "$expr" | cut -d '"' -f2)
|
|
416
|
+
|
|
417
|
+
if [[ -L "$target" ]]; then
|
|
418
|
+
resolved=$(readlink "$target")
|
|
419
|
+
if [[ "$resolved" != /* ]]; then
|
|
420
|
+
resolved="$(cd "$(dirname "$target")" && pwd)/$resolved"
|
|
421
|
+
fi
|
|
422
|
+
/bin/rm -f "$resolved"
|
|
423
|
+
else
|
|
424
|
+
/bin/rm -rf "$target"
|
|
425
|
+
fi
|
|
426
|
+
`
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await fs.chmod(mockOsa, 0o755)
|
|
430
|
+
|
|
431
|
+
const target = await createFile({
|
|
432
|
+
name: 'file2'
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const link = path.join(path.dirname(target), 'file2_sym')
|
|
436
|
+
await fs.symlink(target, link)
|
|
437
|
+
|
|
438
|
+
const result = await runRm([link], {
|
|
439
|
+
env: {
|
|
440
|
+
HOME: home,
|
|
441
|
+
PATH: `${mockBin}:${process.env.PATH}`,
|
|
442
|
+
SAFE_RM_TRASH: ''
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
t.is(result.code, 0, 'exit code should be 0')
|
|
447
|
+
t.false(await pathExists(link), 'symlink should be removed')
|
|
448
|
+
t.true(await pathExists(target), 'target file should remain')
|
|
449
|
+
})
|
|
275
450
|
}
|
package/test/helper.js
CHANGED
|
@@ -58,13 +58,13 @@ const generateContextMethods = (
|
|
|
58
58
|
|
|
59
59
|
// Helper function to run rm commands
|
|
60
60
|
function runRm (args, {
|
|
61
|
-
input =
|
|
61
|
+
input = [],
|
|
62
62
|
command = rm_command,
|
|
63
63
|
env: arg_env = {}
|
|
64
64
|
} = {}) {
|
|
65
|
-
return new Promise(resolve => {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
66
|
const env = {
|
|
67
|
-
|
|
67
|
+
...process.env,
|
|
68
68
|
...{
|
|
69
69
|
SAFE_RM_TRASH: t.context.trash_path
|
|
70
70
|
},
|
|
@@ -80,21 +80,33 @@ const generateContextMethods = (
|
|
|
80
80
|
|
|
81
81
|
child.stdout.on('data', data => {
|
|
82
82
|
stdout += data.toString()
|
|
83
|
+
|
|
84
|
+
if (/\?\s*$/.test(stdout)) {
|
|
85
|
+
if (!child.stdin) {
|
|
86
|
+
reject(new Error('Child process does not support stdin'))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const this_input = input.shift()
|
|
91
|
+
|
|
92
|
+
if (!this_input) {
|
|
93
|
+
reject(new Error('Need more input'))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
child.stdin.write(`${this_input}\n`)
|
|
98
|
+
stdout += `${this_input}\n`
|
|
99
|
+
|
|
100
|
+
if (input.length === 0) {
|
|
101
|
+
child.stdin.end()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
83
104
|
})
|
|
84
105
|
|
|
85
106
|
child.stderr.on('data', data => {
|
|
86
107
|
stderr += data.toString()
|
|
87
108
|
})
|
|
88
109
|
|
|
89
|
-
if (input) {
|
|
90
|
-
if (!child.stdin) {
|
|
91
|
-
throw new Error('Child process does not support stdin')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
child.stdin.write(input)
|
|
95
|
-
child.stdin.end()
|
|
96
|
-
}
|
|
97
|
-
|
|
98
110
|
child.on('close', code => {
|
|
99
111
|
const resolved = {
|
|
100
112
|
code,
|
|
@@ -105,6 +117,7 @@ const generateContextMethods = (
|
|
|
105
117
|
log(command, 'result:', resolved)
|
|
106
118
|
|
|
107
119
|
resolve(resolved)
|
|
120
|
+
child.kill()
|
|
108
121
|
})
|
|
109
122
|
})
|
|
110
123
|
}
|
package/.travis.yml
DELETED