#!/bin/sh
#==============================================================================
# R.A.L.F. (Recover A Lost File)         (c) 2008 by Itzchak Rehberg & IzzySoft
# Syntax: $0 <filename>
#------------------------------------------------------------------------------
# Ralf is just a shell script that automates the manual steps required to
# restore a (accidentally) deleted file from a modern ext2/ext3 file system.
# For other *nix file systems there may be easier solutions - though Rolf
# should also be able to handle them, as long as they work with iNodes.
#------------------------------------------------------------------------------
# Requires: photorec OR foremost
# Requires: sleuthkit (at least fsstat, dls and fls from that package)
#==============================================================================
# $Id: ralf 41 2008-07-20 17:35:30Z izzy $

#==========================================================[ Configuration ]===
# for explanation of the options, see ext3undelrc
#-------------------------------------------------[ File Systems and Paths ]---
TMPDIR=/tmp
MNTFILE=${TMPDIR}/undel_FIFO
TMP=$TMPDIR/undel_FIFO2$$
RESTDIR=recover

#--------------------------------------------------------[ runtime options ]---
SILENT=0
DEBUG=0

#-------------------------------------------------------[ foremost options ]---
FILESPEC="everything"
USETS=0
USEQUICK=1
USEQUIET=1
READONLY=0
VERBOSE=1
ALLHEAD=1

#-------------------------------------------------------[ photorec options ]---
WHOLESPACE=0

RECOPROG=photorec

#-----------------------------------------------[ Read configuration files ]---
[ -e /etc/ext3undel/ext3undelrc ] && . /etc/ext3undel/ext3undelrc
[ -e $HOME/.ext3undel/ext3undelrc ] && . $HOME/.ext3undel/ext3undelrc

#===================================================[ Helper / SubRoutines ]===
shopt -s extglob
trap "cleanUp ; exit 2" 2 3 15
#-----------------------------------[ Check whether we are running as root ]---
function checkRoot() {
  local uid=`id -u`
  [ $uid -gt 0 ] && {
    echo "This script must be run as root (or at least with sudo)."
    exit 1
  }
}

#-------------------------------[ Make sure needed binaries exist in $PATH ]---
function checkBins() {
  local fm=1
  [ "$RECOPROG" = "photorec" -a -z "`which photorec`" ] && RECOPROG=foremost
  [ "$RECOPROG" = "foremost" -a -z "`which foremost`" ] && {
    echo
    echo "Neither the 'photorec' nor the 'foremost' executable could be found"
    echo "in your \$PATH - but we need one of them. They usually ship in a"
    echo "package with the same name (i.e. 'photorec' or 'foremost')."
    echo
    fm=0
  }
  [ -z "`which fls`" ] && {
    echo
    echo "Could not find the 'fls' executable in your \$PATH. This is part of the"
    echo "sleuthkit package."
    echo
    fm=0
  }
  [ -z "`which fsstat`" ] && {
    echo
    echo "Could not find the 'fsstat' executable in your \$PATH. This is part of the"
    echo "sleuthkit package."
    echo
    fm=0
  }
  [ -z "`which dls`" ] && {
    echo
    echo "Could not find the 'dls' executable in your \$PATH. This is part of the"
    echo "sleuthkit package."
    echo
    fm=0
  }
  [ $fm -ne 1 ] && {
    echo
    echo "One or more of the essential tools required to recover your file cannot"
    echo "be found. Please make sure you have them installed, and they can be found"
    echo "in your \$PATH."
    echo
    exit 2
  }
}

#--------------------------------------------------[ Display help and exit ]---
function syntax() {
  echo
  echo "Syntax:"
  echo "  ralf <filename> [options]"
  echo "where <filename> specifies the name of the file to restore - either"
  echo "relative to the current working directory, or with full (absolute) path."
  echo
  echo "Available options:"
  echo "+d / --debug	Enable Debug Output"
  echo "-d / --nodebug	Disable Debug Output"
  echo "+s / --silent	Make output less verbose"
  echo "-s / --nosilent	Display progress messages"
  echo "--freespace	Recover from free space only"
  echo "--wholespace	Recover from whole space"
  echo
  exit 0
}

#-------------------------------[ Remove leading and trailing white spaces ]---
function trim() {
  echo "$1"|sed 's/^\s*//;s/\s*$//'
}

#---------------------------------------------[ Read a number (user input) ]---
# $1: Prompt message
# opt $2: Min
# opt $3: Max
function readnum() {
  read -p "$1" rd
  local tn=`echo $rd|sed 's/[0-9]//g'`
  local min=$2
  local max=$3
  [ -n "$tn" ] && {
    echo "Please enter only digits!"
    readnum "$1" "$2" "$3"
  }
  [ -z "$min" ] && min=0
  [ -z "$max" ] && max=99999999
  [ $rd -lt $min -o $rd -gt $max ] && {
    echo "Please enter a number between $2 and $3!"
    readnum "$1" "$2" "$3"
  }
}

#---------------------------------------------------------[ Read Y/N input ]---
function readyn() {
  read -n 1 rd
  echo
  rd=`echo $rd|tr [:upper:] [:lower:]`
  if [ "$rd" = "y" -o "$rd" = "j" ]; then
    rd=1
  elif [ "$rd" = "n" ]; then
    rd=0
  else
    echo -n "Please enter 'y' for 'yes', 'n' for 'no' - or press Ctrl-C to abort: "
    readyn
  fi
}

#-------------------------------------------[ Select Destination directory ]---
function selDest() {
  local rd
  echo
  echo "Please select the file system to store the recovered files to."
  echo "(This must be on a different device than you restore from)"
  echo
  printf "%2s| %-7s| %-15s| %-32s| %-16s\n" "ID" "Type" "Device" "MountPoint" "Size"
  echo "--+--------+----------------+---------------------------------+----------------"
  typeset -i i=0
  local uLINE=""
  local SKIP=1
  mkfifo $MNTFILE
  df -h -l -T >$MNTFILE &
  while read line; do
    [ $SKIP -eq 1 ] && { # skip the header
      SKIP=0
      continue
    }
    if [ -n "$(trim "$uLINE")" ]; then # wrapped line continued
      line="${uLINE} $line"
    else
      [ -z "$(trim "`echo $line|awk '{print $7}'`" )" ] && { # line wrapped?
        uLINE="$line"
	continue
      }
    fi
    uLINE=""
    if [[ "$line" == /dev* ]]; then # skip pseudo filesystems
      i+=1
      devs[$i]=`echo $line|awk '{print $1}'`
      [ "${devs[$i]}" != "$DEV" ] && echo "$i $line"|awk '{printf "%2d| %-7s| %-15s| %-32s| %-16s\n", $1, $3, $2, $8, $4 }'
      stores[$i]=`echo $line|awk '{print $7}'`
    fi
  done<$MNTFILE
  rm -f $MNTFILE
  readnum "Destination ID: " 1 $i
  if [ "${devs[$rd]}" = "$DEV" ]; then
    echo
    echo "Invalid selection: Source and Target file systems must not be identical!"
    echo "This way you may destroy your lost data permanently. Are you sure to"
    echo -n "proceed (y/n)? "
    readyn
    echo
    if [ $rd -eq 1 ]; then
      recoTo=${stores[$rd]}/$RESTDIR
    else
      li1 "Aborting on user request."
      cleanUp
      exit 0
    fi
  else
    STORETO=${stores[$rd]}
  fi
  echo
}

#--------------------------------------------------------[ Output progress ]---
function li1() {
  [ $SILENT -eq 0 ] && echo "* $1"
}

#-----------------------------------------------[ Output debug information ]---
function debugMsg() {
  [ $DEBUG -gt 0 ] && echo "# debug: $1"
}

#--------------------------------------------------------[ Verify filename ]---
function fileName() {
  if [[ "$1" == /* ]]; then
    uFILE=$1
  else
    uFILE="`pwd`/$1"
  fi
  li1 "FileName set to '$uFILE'"
}

#-----------------------------------------------[ Select Source MountPoint ]---
function selSrc() {
  local mid
  typeset -i i=0
  mounts[0]=""
  local uLINE=""
  local SKIP=1
  mkfifo $TMP
  mkfifo $MNTFILE
  df -h -l -T >$MNTFILE &
  while read line; do
    [ $SKIP -eq 1 ] && { # skip the header
      SKIP=0
      continue
    }
    if [ -n "$(trim "$uLINE")" ]; then # wrapped line continued
      line="${uLINE} $line"
    else
      [ -z "$(trim "`echo $line|awk '{print $7}'`" )" ] && { # line wrapped?
        uLINE="$line"
	continue
      }
    fi
    uLINE=""
    if [[ "$line" == /dev* ]]; then # skip pseudo filesystems
      echo $line|awk '{print $7}'>>$TMP &
      uDEV[$i]=`echo $line|awk '{print $1}'`
      uMOUNT[$i]=`echo $line|awk '{print $7}'`
      uFSTYPE[$i]=`echo $line|awk '{print $2}'`
      i+=1
    fi
  done<$MNTFILE
  sort -n -r <$TMP>$MNTFILE &
  while read line; do
    if [[ "$uFILE" == $line* ]]; then
      i=0
      while (( i < ${#uMOUNT[*]} )); do
        if [[ "$line" = "${uMOUNT[$i]}" ]]; then
          li1 "Selected file should be on device ${uDEV[$i]}, mounted to ${uMOUNT[$i]} (${uFSTYPE[$i]})."
	  DEV=${uDEV[$i]}
	  MNT=${uMOUNT[$i]}
	  FSTYPE=${uFSTYPE[$i]}
	  break 2
        fi
        i+=1
      done
    fi
  done <$MNTFILE
  rm -f $TMP
  rm -f $MNTFILE
  li1 "Evaluated '$MNT' as corresponding mount point, using '$FSTYPE' file system."
  [ "$FSTYPE" != "ext2" -a "$FSTYPE" != "ext3" ] && echo "! WARNING: This is not an ext2/ext3 file system, so our algorithm may fail!"
}

#--------------------------------------------[ Splitup filename for search ]---
function splitFileName() {
  if [ "$MNT" = "/" ]; then
    SEARCH=${uFILE:1}
  else
    SEARCH=${uFILE#$MNT/*}
  fi
  li1 "Setting SearchString relative to mountpoint ('$SEARCH')"
  SEARCH=`echo "$SEARCH"|awk {'gsub("*",".*");gsub("?",".{1}");gsub("/","\\\/");print $0}'`
  li1 "Translating wildcards to RegExp ('$SEARCH')"
}

#-----------------------------------------------------[ Check for Symlinks ]---
function symlinkCheck() {
  local left="$uFILE"
  local check=""
  while [ -n "$left" ]; do
    if [ -n "$right" ]; then
      right="${left##*/}/${right}"
    else
      right="${left##*/}"
    fi
    left="${left%/*}"
    [ -n "$left" ] && check=`readlink $left`
    [ -n "$check" ] && {
      uFILE="${check}/${right}"
      left="$uFILE"
      right=""
    }
  done
  li1 "Real filename: '$uFILE'"
}

#---------------------------------------------------[ Get PhotoRec Version ]---
function photoRecVer() {
  local TMP="${TMPDIR}/undel_xFIFO.$$"
  mkfifo $TMP
  photorec --help>$TMP &
  while read line; do
    local ver=`echo $line|awk '{print $2}'`
    break
  done<$TMP
  pr_maj=${ver%.*}
  pr_min=${ver#*.}
  pr_min=${pr_min%,*}
  [ "$pr_min" != "${pr_min%-*}" ] && let pr_min=${pr_min%-*}-1
  rm -f $TMP
}

#--------------------------------------------------[ Restore with PhotoRec ]---
function recoPhotoRec() {
  photoRecVer
  li1 "Extracting file with PhotoRec v${pr_maj}.${pr_min}"
  cmd="photorec /d ${STORETO}/${RESTDIR} /cmd $DUMPFILE partition_i386"
  [ "$FSTYPE" = "ext3" -o "$FSTYPE" = "ext2" ] && cmd="${cmd},options,mode_ext2"
  if [ $pr_maj -gt 6 -o $pr_maj -eq 6 -a $pr_min -ge 9 ]; then
    if [ "$FILESPEC" = "everything" ]; then
      cmd="${cmd},fileopt,everything,enable,search"
    else
      cmd="${cmd},fileopt,everything,disable,$FILESPEC,enable,search"
    fi
    if [ $WHOLESPACE -eq 0 ]; then
      cmd="${cmd},freespace"
    else
      cmd="${cmd},wholespace"
    fi
  else
    if [ "$FILESPEC" = "everything" ]; then
      cmd="${cmd},search"
    else
      cmd="${cmd},fileopt,$FILESPEC,enable,search"
    fi
  fi
  debugMsg "Executing '$cmd'"
  eval $cmd
  rc=$?
}

#--------------------------------------------------[ Restore with foremost ]---
function recoForemost() {
  li1 "Extracting file with foremost..."
  cmd="foremost "
  [ $USEQUICK -eq 1 ] && cmd="$cmd -q"
  [ $USETS -eq 1 ] && cmd="$cmd -T"
  [ $USEQUIET -eq 1 ] && cmd="$cmd -Q"
  [ $READONLY -eq 1 ] && cmd="$cmd -w"
  [ $VERBOSE -eq 1 ] && cmd="$cmd -v"
  [ $ALLHEAD -eq 1 ] && cmd="$cmd -a"
  local FILEXT
  if [ -z "$FILESPEC" -o "$FILESPEC" = "everything" ]; then
    FILEXT="all";
  else
    FILEXT="$FILESPEC"
  fi
  cmd="$cmd -t $FILEXT -o ${STORETO}/$RESTDIR ${DUMPFILE} >/dev/null"
  debugMsg "Executing '$cmd'"
  eval $cmd
  rc=$?
}

#----------------------------------------------------[ Obtain the FileType ]---
function getFileType() {
  local FEXT=`echo ${1##*.}|tr [:upper:] [:lower:]`
  local EXT
  local DESC
  local FTLIST="filetypes.${RECOPROG}"
  if [ -f /etc/ext3undel/$FTLIST ]; then
    FTLIST="/etc/ext3undel/$FTLIST"
  elif [ -f $HOME/.ext3undel/$FTLIST ]; then
    FTLIST="$HOME/.ext3undel/$FTLIST"
  elif [ -f ${0%/*}/$FTLIST ]; then
    FTLIST="${0%/*}/$FTLIST"
  fi
  while read line; do
    EXT=${line%%;*}
    [ "$FEXT" != "$EXT" ] && continue
    DESC=${line##*;}
    break
  done<$FTLIST
  if [ -z "$DESC" ]; then
    li1 "FileType '$FEXT' is unknown to $RECOPROG - so we let it check all it knows."
    FILESPEC="everything"
  else
    echo "File has the extension '$EXT'. According to the list of known file types, it"
    echo "probably is a '$DESC' file."
    echo "Shall we handle it as such (y), or better check all other file types"
    echo -n "as well (y/n)? "
    readyn
    echo
    if [ $rd -eq 1 ]; then
      FILESPEC="$EXT"
      li1 "FileType set to '$EXT' ('$DESC')"
    else
      FILESPEC="everything"
      li1 "FileType set to 'everything' (all that's supported)"
    fi
  fi
}

#-------------------------------------------------[ Find iNode information ]---
# $1: what to search (-d for deleted, -u for undeleted, "all" for all)
# $2: "all" for no "r/r" restriction
function getINode() {
  case "$1" in
    "all") FLS="fls -r -p $DEV|grep -v '(realloc)'|egrep '$SEARCH'";;
    "-d"|"-u") FLS="fls -r $1 -p $DEV|grep -v '(realloc)'|grep 'r/r'|egrep '$SEARCH'";;
  esac
  mkfifo $TMP
  debugMsg "$FLS"
  eval $FLS>$TMP &
  typeset -i i=0
  while read line; do
    if [ "$(echo $line|awk '{print $2}')" = "*" ]; then
      iFILE[$i]=`echo "'$line '"|awk '{print $4}'`
      iNODE[$i]=`echo "'$line'"|awk '{print $3}'`
    else
      iFILE[$i]=`echo "'$line '"|awk '{print $3}'`
      iNODE[$i]=`echo "'$line'"|awk '{print $2}'`
    fi
    iNODE[$i]=${iNODE%*:}
    i+=1
  done <$TMP
  debugMsg "Found ${#iNODE[*]} entries"
  rm -f $TMP
}

#------------------------------------------------[ Restore a file (or not) ]---
# $1: filename (relative to mountpoint)
# $2: iNode#
# $3: directory iNode substituted
function fileRestore() {
  if [ -n "$3" ]; then
    local SUBST=$3
  else
    local SUBST=0
  fi
  if [ $SUBST -eq 0 ]; then
    local NAM="file"
  else
    local NAM="substituted directory"
  fi
  echo
  echo -n "Found $NAM '$MNT/$1' on iNode '$2'. Restore (y/n)? "
  readyn
  if [ $rd -eq 1 ]; then
    echo
    if [ $SUBST -eq 0 ]; then
      getFileType "$1"
    else
      getFileType "$uFILE"
    fi
    mkfifo $TMP
    fsstat $DEV > $TMP &
    typeset -i min
    typeset -i max
    typeset -i nod=$2
    local range=0
    local start=0
    while read line; do
      if [ $start -eq 0 ]; then
        [[ "$line" == Group:* ]] && start=1
      else
        if [[ "$line" == Inode?Range:* ]]; then
          min=`echo $line|awk '{print $3}'`
	  max=`echo $line|awk '{print $5}'`
	  if [ $nod -gt $min -a $nod -lt $max ]; then
	    li1 "iNode $2 found in range '$min - $max'"
	    range=1
	    continue
	  fi
        elif [ $range -eq 1 ]; then
          if [[ "$line" == Block?Range:* ]]; then
            min=`echo $line|awk '{print $3}'`
            max=`echo $line|awk '{print $5}'`
	    li1 "Creating image of matching block range ($min - $max)"
	    dls $DEV $min-$max >${DUMPFILE}
	    case "$RECOPROG" in
	      "photorec") recoPhotoRec;;
	      "foremost") recoForemost;;
	      *) echo "Ooops - no recovery tool specified???"
	         echo "You normally should not see this message - there seems to be some bug in the script..."
		 ;;
	    esac
	    if [ $rc -eq 0 ]; then
	      echo
	      echo "All recoverable files from the data block range where the requested"
	      echo "file had been stored in have been reconstructed and stored into"
	      echo "  ${STORETO}/$RESTDIR"
	      echo "(if you used PhotoRec, you have to add '.#' to this path, where '#'"
	      echo "is a number). You still have to check these files manually, and"
	      echo "to rename/copy those you want to where you want them."
	    else
	      echo "Ooops! It looks like $RECOPROG failed to recover your file. So we will"
	      echo "exit as well now - with the exit code $RECPROG gave us..."
	      cleanUp
	      exit $rc
	    fi
	    break
	  fi
        fi
      fi
    done<$TMP
    [ $range -eq 0 ] && echo "Could not determine data block range - so no restore, sorry..."
  fi
}

#------------------------------------------------[ cleanUp temporary stuff ]---
function cleanUp() {
  li1 "Cleaning up..."
  echo
  rm -f $TMP $MNTFILE $DUMPFILE
  echo
}

#============================================================[ Do the job! ]===
#---------------------------------------------------[ check pre-requisites ]---
DIRECTHIT=1 # Hopefully we find the files iNode
checkRoot
checkBins
#-----------------------------------------------------[ parse command line ]---
[ -z "$1" ] && syntax
case "$1" in
  "-h"|"--help"|"-?") syntax;;
  *)
     set -f
     echo
     fileName "$1"
     set +f
  ;;
esac
shift
while [ -n "$1" ]; do
  case "$1" in
    "+d"|"--debug")	DEBUG=1;;
    "-d"|"--nodebug")	DEBUG=0;;
    "+s"|"--silent")	SILENT=1;;
    "-s"|"--nosilent")	SILENT=0;;
    "--freespace")	WHOLESPACE=0;;
    "--wholespace")	WHOLESPACE=1;;
  esac
  shift
done
debugMsg "SILENT=${SILENT}"
debugMsg "DEBUG=${DEBUG}"

#----------------------------------------------------------[ check sources ]---
symlinkCheck
selSrc
[ -z "$DEV" ] && {
  echo
  echo "Sorry - something went wrong, could not determine the source device."
  echo
  cleanUp
  exit 19
}
selDest
if [ -n "$STORETO" ]; then
  DUMPFILE=${STORETO}/dump.$$
else
  echo
  echo "Sorry - something went wrong, we do not have a destination to store to."
  echo
  cleanUp
  exit 2
fi
splitFileName
li1 "Searching for iNode on '$DEV'..."
getINode "-d"
if [ ${#iNODE[*]} -eq 0 ]; then
  DIRECTHIT=0
  echo "No matching iNode found. Shall we try to substitute with the iNode of the"
  echo -n "parent directory (y/n)? "
  readyn
  echo
  if [ $rd -eq 1 ]; then
    SEARCH=${SEARCH%\\*}
    getINode "all"
  fi
fi
if [ ${#iNODE[*]} -eq 0 ]; then
  echo "No matching iNode found. Looks like G.A.B.I. is your last chance."
else
  typeset -i i=0
  if [ $DIRECTHIT -eq 1 ]; then
    while (( i < ${#iNODE[*]} )); do
      fileRestore "${iFILE[$i]}" ${iNODE[$i]} 0
      i+=1
    done
  else # we substitute the directories iNode - so first hit only
    fileRestore "${iFILE[0]}" ${iNODE[0]} 1
  fi
fi
cleanUp
exit 0
