4
\$\begingroup\$

This is a script that emulates a recycle bin in the CLI.
You can undo your last changes and delete files

This is my first bash script so don't hesitate to bash me. Thank you for taking a look.

#! /bin/bash

usage(){
cat << EOF

Sends and restores files to and from the recycle bin
Usage: rm [options] file1 [file2] ...
Files can be either files or directories

    Options:
     -v         verbose mode
     -h         this help message
     -n         dry run
     -r         restore the file(s) specified
     -e         empties the bin
     -s         stop on errors
     -u         undo last batch
     -d <DIR>   specify the recycle directory
            (defaults to '.recycle' in your ~)
     All other options are ignored when moving files. When removing
     permanently, these options are passed to /bin/rm

EOF
}

FILES=""
VERBOSE=0
DRY=0
RESTORE=0
ERRORSTOP=0
TROOT="$HOME"
UNDO=0
RBIN=".recycle"
CURR=`pwd`
EMPTY=0
UNDOFILE=

while getopts “hvnrsued:” OPTION
do
     case $OPTION in
     h)
         usage
         exit 0
         ;;
     v)
         VERBOSE=1
         ;;
     n)
         DRY=1
         ;;
     r)
         RESTORE=1
         ;;
         u)
         UNDO=1
         ;;
     d)
         RBIN=$OPTARG
         ;;
    e) 
         EMPTY=1
         ;;
    s)
         ERRORSTOP=1
         ;;
     ?)
         echo "Invalid option: -$OPTARG" >&2
         usage
         exit 1
         ;;
     esac
done

#setting the undo file name, will be ~/.recycle-log by default
UNDOFILE=$TROOT/$RBIN"-log"
#setting full path for the recycle bin dir
RBIN="$TROOT/$RBIN"

if [ "$VERBOSE" = 1 ]; then
    echo "recycle dir: $RBIN"
    echo "current dir: $CURR"
fi

# creating the recycle bin dir if it does not exist
test -d "$RBIN" || mkdir -p "$RBIN"

if [ ! -d "$RBIN" ]; then
    echo "could not create the directory $RBIN" >&2
    exit 1
fi

# if -u was passed, undo and exit
if [ "$UNDO" = 1 ]; then
    if [ -f "$UNDOFILE" ]; then
        source $UNDOFILE
        rm $UNDOFILE
        echo "undone" >&2
        exit 0
    else
        echo "no undo file found, cannot undo" >&2
        exit 1
    fi
fi

# resetting undo file for changes to come
if [ -f "$UNDOFILE" ]; then
    rm $UNDOFILE
fi

shift $(( OPTIND - 1 ))

# if -e or -u were passed, no arguments is ok
# if not, then the used should provide at least
# one filename
if [ "$EMPTY" = 0 ]; then
    if [ -z "$1" ]; then
        echo "you should pass at least one filename" >&2
        echo "use -h for help" >&2
        exit 1
    fi
else
    # if -e was passed, empty the directory
    if [ "$DRY" = 0 ]; then
        rm -r "$RBIN"
        mkdir -p "$RBIN"
    fi
    if [ "$VERBOSE" = 1 ]; then
        echo "bin emptied" >&2
    fi
    exit 0
fi

# setting the in and out dir
# By default, in is the current dir and out is the bin dir
IN="$CURR"
OUT="$RBIN"

# but if -r was passed, switch them
if [ "$RESTORE" = 1 ]; then
    let IN="$RBIN"
    let OUT="$CURR"
fi

if [ "$IN" = "$OUT" ]; then
    echo "Error: you are trying to move files to same directory" >&2
    exit 1
fi

orig=
dest=

# preparing undo file
touch $UNDOFILE

while [ "$1" ]; do

    orig="$IN/$(basename $1)"
    dest="$OUT/$(basename $1)"

    if [ "$DRY" = 1 ]; then
        if [ "$RESTORE" = 1 ]; then
        echo "restore $orig" >&2
        else
            echo "delete $orig" >&2
        fi
    fi
    if [ -e "$orig" ]; then
        # if file already exists, rename it by appending a number
        if [ -e "$dest" ]; then
            version=2
            while [ -e "$dest$version" ]; do
                let version=$version+1
            done
            if [ "$DRY" = 0 ]; then
                mv "$dest" "$dest$version"
                echo "mv $dest$version $dest" >> $UNDOFILE
            fi
            if [ "$VERBOSE" = 1 ];then
                echo "moving old existing $dest to $dest$version" >&2
            fi
        fi
        if [ "$DRY" = 0 ]; then
            mv "$orig" "$dest"
            echo "mv $dest $orig" >> $UNDOFILE
        fi
        if [ "$VERBOSE" = 1 ]; then
            echo "$orig → $dest" >&2
        fi
    else
        if [ "$VERBOSE" = 1 ]; then
            echo "file '$(basename $1)' could not be processed" >&2
        fi
        if [ "$ERRORSTOP" = 1 ]; then
            echo "there were errors" >&2
            exit 1
        fi
    fi
    shift
done
\$\endgroup\$
3
  • \$\begingroup\$ Looks nice. I'd reconsider naming it rm. Something like srm - safe remove or urm - undo-able remove sounds better and you can also leave the command in place. If you'd override the default rm this might lead to confusion of users which are not familiar with the system. \$\endgroup\$ – Bobby Apr 11 '12 at 7:17
  • \$\begingroup\$ Oh definitely. Thanks for pointing this out. This reference to rm was from when I began writing it and I forgot to change it. I was considering "del" or "trash" \$\endgroup\$ – Xananax Apr 11 '12 at 17:07
  • \$\begingroup\$ This is the original script I was inspired from. Began by copy-pasting it but forgot to edit the "usage" part: snipplr.com/view/57640 \$\endgroup\$ – Xananax Apr 11 '12 at 17:18
4
\$\begingroup\$
  1. You don't need a space in the current shebang line. Anyway, Greg's wiki and others recommend #!/usr/bin/env bash (for portability, but I can't find a reference at the moment).
  2. Any command substitutions should use "$(foo)" rather than `foo`. That way, you actually get what the command returns (except for trailing newlines) rather than a whitespace-clobbered version. For example, CURR="$(pwd)", or even better:

    CURR="$(pwd; echo x)"
    CURR="${CURR%x}"
    
  3. Don't use actual opening and closing quotation marks (“hvnrsued:”). This type of quotes are just regular characters in Bash, so it's as if you had added a -“ and -” option. More about quotes.
  4. Some lines are not indented properly. Incorrect indentation makes the code hard to read, and in the worst case can introduce errors because of misunderstandings of context.
  5. Use the safe version of all calls which support it: command --option1 --option2 -- "$file1" "$file2". The double dashes separate options from filenames in most *nix commands, to avoid interpretation of filenames as options, or, in the worst case, acting on the wrong file. Other commands have special flags (-e, --regex in grep) to prefix other options to avoid treating the second option as another flag. Compare (untested)

    printf %s -foo | grep -foo # No such file oo
    

    with

    printf %s -foo | grep -e -foo
    
  6. echo is one of those unfortunate commands which don't have such an option. Use printf if you don't know what the input will be. Compare (untested)

    string=-escape
    echo "$string" # No such option -s
    

    with

    printf %s "$string"
    
  7. Create lots of test cases. This is such a dangerous script that you'd better have some proof that complex file names created by accident or malicious users don't have an opportunity to wreak havoc. I can thoroughly recommend shunit2, which is even in the Ubuntu repositories. Example tests.

  8. You should add set -o errexit -o noclobber -o nounset -o pipefail at the beginning of the file to ensure stricter error checking. Only re-enable clobbering for the smallest possible code block (i.e., the single line where you use redirection to file) if necessary.
  9. Declare read-only variables as such (help readonly, help declare) to avoid accidental modification. This should be done separately from assignment - If you do it together (declare -r foo="$(false)"), you'll find that the exit code of the command substitution is lost. Compare

    declare -r foo="$(false)"
    echo $?
    

    with

    bar="$(false)"
    echo $?
    declare -r bar
    echo $?
    
\$\endgroup\$
3
  • \$\begingroup\$ Thanks for the kind comment and all the advice! I'll try to implement that, though I have no idea about how to do the safe version of calls. All the examples of getOpts used short versions. I began writing my own loop and parse but gave up. And about the indentation, that's the copy-paste from vim that messed it up...I corrected it but apparently forgot some parts. \$\endgroup\$ – Xananax Apr 11 '12 at 17:09
  • \$\begingroup\$ On another note, what's the policy around here? When I update my code, should I just edit my post? \$\endgroup\$ – Xananax Apr 11 '12 at 17:09
  • \$\begingroup\$ @Xananax You should not update the code - It would make the answers obsolete. Rather, when you feel you've got a much improved version, you could post a separate question asking for more advice. \$\endgroup\$ – l0b0 Sep 28 '12 at 7:27
1
\$\begingroup\$

consider set -u for checking for uninitialized variables.

The general convention is to use small letters for variables you don't export

files=""
verbose=0
dry=0
restore=0
errorstop=0
undo=0
rbin=".recycle"
curr=$(pwd)
empty=0
undofile=

Why do you need this? Do you change $HOME in your script?

troot="$HOME"

... An echo that accepts a file argument too

xecho() {
  case "$1" in
     -f) cat;;
     *) echo $*;;
  esac
}

Consider making an error function

err() { xecho $@ >&2 } errexit() { local exitval=shift; err "$2" >&2 exit $exitval }

I feel cat is cleaner than multiple echoes, but it is just a preference. Also consider making verbose a function

verbose() {
   [ "$VERBOSE" = 1 ] && xecho $@
}
warn() {
   verbose $@ >&2
}


verbose -f <<EOF
recycle dir: $RBIN
current dir: $CURR
EOF

You could also do a general tryexec function that checks if dryrun and if so, just echos the commands and else execute them. This would be better than the current $DRY checks distributed in the code.

tryexec() {
   if [ "$DRY" = 0 ]
   then
      cat
   else
      bash
   fi
}

You don't need to test if dir exists if you are going to use mkdir -p More over, your message in that case is a bit redundant. mkdir's error message says exactly the same thing.

# creating the recycle bin dir if it does not exist
mkdir -p "$RBIN" || exit 1

consider making undo_file a separate function, also it is nice to see validation of parameters in front. Removing the undo file is done after this any way in main body, so that is not needed twice.

undo_file() {
    [ -f "$1" ] || {echo "no undo file found, cannot undo" >&2 ; exit 1}
    source $1
    errexit 0 "undone"
}

# if -u was passed, undo and exit
[ "$UNDO" = 1 ] && undo_file $UNDOFILE

You can just use -f to rm rather than checking if file exists or not.

# resetting undo file for changes to come
rm -f $UNDOFILE

Consider doing this at the getopt stage itself. That is the right place to ensure correct number of arguments to each option. I would also consider just printing usage if the number of arguments do not match.

# if -e or -u were passed, no arguments is ok
# if not, then the used should provide at least
# one filename
[ "$EMPTY" = 0 ] && [ -z "$1" ] && errexit 1 -f <<EOF
you should pass at least one filename
use -h for help
EOF

consider making an empty_dir function

empty_dir() { 
    tryexec <<EOF
        rm -r "$RBIN"
        mkdir -p "$RBIN"
    EOF

    errexit 0 "bin emptied"
}

Consider making restore a function. and use it this way

# if -r was passed, switch
if [ "$RESTORE" = 1 ]; then
    restore "$RBIN" "$CURR"
else
    restore "$CURR" "$RBIN"
fi

This should come before the previous.

nextversion(){
   local version=2
   while [ -e "$1$version" ]; do
       let version=$version+1
   done
   echo $version
}

restore() {
   local in="$1"
   local out="$2"
   [ "$in" = "$out" ] && errexit 1 "Error: you are trying to move files to same directory"
   local orig=
   local dest=

   # preparing undo file
   touch $UNDOFILE

   while [ "$1" ]; do

     orig="$IN/$(basename $1)"
     dest="$OUT/$(basename $1)"

     if [ -e "$orig" ]; then
        # if file already exists, rename it by appending a number
        if [ -e "$dest" ]; then
            version=$(nextversion $dest)
            tryexec <<EOF
                mv "$dest" "$dest$version"
                echo "mv $dest$version $dest" >> $UNDOFILE
EOF
            warn "moving old existing $dest to $dest$version"
        fi
        tryexec <<EOF
            mv "$orig" "$dest"
            echo "mv $dest $orig" >> $UNDOFILE
EOF
        warn "$orig → $dest"
     else
        warn "file '$(basename $1)' could not be processed"
        [ "$ERRORSTOP" = 1 ] && errexit 1 "there were errors"
     fi
     shift
   done
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.