#!/usr/bin/bash

# This script runs as a bash hook to facilitate obfuscation of sensitive data 
# in the command history.  The problem is that nv commands run as a one-shot 
# sub-process of the bash shell in which they are invoked.  Whereas only the 
# child process understands the command semantics and knows what should be 
# hidden, updating the command history occurs in the parent process under 
# Linux control.  Linux provides a hook called PROMPT_COMMAND, which, if 
# defined, runs user code every time the prompt is displayed.  We point 
# PROMPT_COMMAND at this script, which modifies the history based on data 
# left by the child process.
# Set PROMPT_COMMAND to "source <path to this script>".
# It is important to use "source", so that this script runs in the parent 
# process and can access the same history.

# NV command subprocess informs parent that a command should be censored in 
# history by writing to a temporary file.  Temporary file name is derived 
# from PID of the shell running the commands.  Thus we do not interfere with 
# any other shells concurrently active.
cenfname="/tmp/cen$$"

# The temporary file should only exist when a substitution needs to be made in history.
if [ -f "$cenfname" ]; then

    # Save bash flags
    saved_flags=$-

    # Prevent expansion of * to a list of file names
    set -f

    # Get the actual command that Linux wrote to history, including command 
    # number that Linux prepends.  Using "fc" rather than "history | tail -1"
    # handles a multi-line command correctly, e.g. if user didn't close quotes
    # and was prompted to close them on the next line.
    # Remove any extra spaces before comparing with the parsed command.
    gross_actual_cmd=$(fc -l -1 | tr -s ' ')

    # Proceed if we found something in history.
    if [ ! -z "$gross_actual_cmd" ]; then

        # Extract the user's command by removing the command number which is 
        # the first word.
        net_actual_cmd=$(echo $gross_actual_cmd | cut -d " " -f2-)

	# Proceed if we still have data from history - extra precaution.
        if [ ! -z "$net_actual_cmd" ]; then

            # Initialize the command as it was parsed by Linux before passing
            # to nv, which we will read from odd-numbered lines of the
            # temporary file.  This might differ from the command entered by
            # user in removal of escape characters, spaces, and variable
            # substitution.
            parsed_cmd=""

            # Initialize the censored command which we will read from
            # even-numbered lines of the temporary file.
            censored_cmd=""

            # Read all lines of temporary file in pairs.  Add a space to each
            # line to separate last word from first word of next line.
            lineno=0
            while read -r line
            do
                line+=" "
                lineno=$((lineno+1))
                if [ $((lineno%2)) -eq 0 ]
	        then
		    censored_cmd+=$line
	        else
		    parsed_cmd+=$line
	        fi
	    done <  "$cenfname"

            # Initialize the string in which we will replay Linux parsing
            reparsed_cmd=""

            # Initialize the string that we will write back to history
            new_cmd=""

            # Create array versions of all these strings
            net_actual_arr=($net_actual_cmd)
            parsed_arr=($parsed_cmd)
            censored_arr=($censored_cmd)
            reparsed_arr=($reparsed_cmd)
            new_arr=($new_cmd)

            # Initialize indeces
            net_actual_idx=0
            parsed_idx=0
            reparsed_idx=0
            new_idx=0

	    # Send any words before 'nv' in actual command, e.g. 'time',
	    # straight to the buffer to be written to history, before
	    # starting to process the nv command
	    while [ $net_actual_idx -lt ${#net_actual_arr[@]} ]
            do
                w_actual="${net_actual_arr[$net_actual_idx]}"
                if [[ "$w_actual" != "nv" && "$w_actual" != "nv-interactive" ]];
                then
                    new_arr[$new_idx]=$w_actual
                    new_idx=$((new_idx+1))
                    net_actual_idx=$((net_actual_idx+1))
                else
                    break
		fi
            done

            # Loop over all words in parsed command, replacing where needed
            while [ $parsed_idx -lt ${#parsed_arr[@]} ]
            do

                # Reset variable counting words in case of a quoted string
                num_words_in_quotes=0

                # Get next word from parsed command
                w_parsed=${parsed_arr[$parsed_idx]}

                # If parsed_idx < reparsed_idx, it means the user's command
                # was expanded by variable substitution, and we should take a
                # parsed word from the result of that substitution
                if [ $parsed_idx -lt $reparsed_idx ]
                then
                    w_actual="${reparsed_arr[$parsed_idx]}"

                # Otherwise take next word in actual command
                else
                    w_actual="${net_actual_arr[$net_actual_idx]}"
                    net_actual_idx=$((net_actual_idx+1))

                    # Stop scanning if out of words in actual history
                    # This could happen if data was prompted after command entered
                    if [ -z "${w_actual}" ];
                    then
                        break
                    fi

                    # If the word in the actual command begins with a single
                    # or double quote, scan forward until close quote and
                    # combine all those words into one
                    unset quote
                    if [[ "$w_actual" == "\""* ]];
                    then
                        quote="\""
                    elif  [[ "$w_actual" == "\'"* ]];
                    then
                        quote="\'"
                    fi
                    if [[ "${quote}" == "\"" ]] || [[ "${quote}" == "\'" ]];
                    then
                        # Compensate for unconditional +1 at end of loop
                        num_words_in_quotes=-1

                        # Number of quotes we have seen so far
                        numquote=0

                        # Each word of quoted string will go in wordinquote,
                        # and they will be concatenated in w_actual
                        wordinquote=$w_actual
                        w_actual=''

                        # Continue till we see the closing quote of the same
                        # kind, or end of input, e.g. \' which bash will pass
                        # without a closing quote.  Condition for end of
                        # command is -le rather than -lt above, because
                        # net_actual_idx has already been incremented, and we
                        # want to include the last word.
                        while [[ $numquote -lt 2 ]] && [ $net_actual_idx -le ${#net_actual_arr[@]} ]
                        do
                            # Count the number of quotes in this word by
                            # removing all other characters and taking length
                            quotesinword=${wordinquote//[^$quote]/}
                            numquote=$((numquote+${#quotesinword}))

                            # Append with space in between
                            w_actual+=" "
                            w_actual+=$wordinquote

                            # Get next word in quotes from actual command
                            wordinquote="${net_actual_arr[$net_actual_idx]}"
                            net_actual_idx=$((net_actual_idx+1))

                            # Track number of words in quoted string
                            num_words_in_quotes=$((num_words_in_quotes+1))
                        done
                    fi

                    # Tentatively add this word to output
                    new_arr[$new_idx]=$w_actual
                    new_idx=$((new_idx+1))

                    # Add the word to reparsed if linux parsing did not change it
                    if [ "$w_actual" == "$w_parsed" ]
                    then
                        reparsed_arr[$reparsed_idx]=$w_actual
                        reparsed_idx=$((reparsed_idx+1))
                    else
                        # Reproduce the expansion done by original parsing
                        extra_cmd=`eval "echo $w_actual"`

                        # Convert to array and append to reparsed
                        extra_arr=($extra_cmd)
                        reparsed_arr=("${reparsed_arr[@]}" "${extra_arr[@]}")
                        reparsed_idx=${#reparsed_arr[@]}

                        # Change the current word to be the first word of the expansion
                        w_actual="${reparsed_arr[parsed_idx]}"
                    fi
                fi

                # Stop scanning if out of words in actual history
                # This could happen if data was prompted after command entered
                if [ -z "${w_actual}" ];
                then
                    break
                fi

                # Now actual command is aligned with parsed command
                # Don't check for equality, because actual may be substring
                # Censor if needed
                w_censored="${censored_arr[$parsed_idx]}"
                if [ $w_censored != $w_parsed ]
                then
                    new_arr[$new_idx-1]=$w_censored
                fi

                # Advance in parsed command, taking any mutil-word
                # quoted string into account
                parsed_idx=$((parsed_idx+num_words_in_quotes+1))
            done

            # Extract the command number in history which is the first word.
            hist_num=$(echo $gross_actual_cmd | sed 's/^ *//g' | cut -d " " -f 1)

            # Delete the original command from history.
            history -d $hist_num

            # Insert the modified command in its place.
            history -s "${new_arr[@]}"
        fi
    fi

    # Restore bash flags
    if [[ "$saved_flags" != *"f"* ]]
    then
	set +f
    fi

    # Delete the temporary file.
    rm -f $cenfname
fi
