Copying a Bash Environment to a New Shell
In Running Base in a New Shell, I presented a script that demonstrates a method for copy the environment variables and aliases from the current Bash shell to a new Bash shell. While preparing a new release of Base, I fixed a number of bugs:
- Rewrite When More Than N Lines
- Bash Escaping Issue
- Bash Escaping Issue (Part 2)
- Bash Escaping Issue (Part 3)
This blog entry presents an updated demonstration script. This
script, sans commentary, is available on GitHub: demo.sh
Note that this demonstration script still has issues! The final version can be found in Abort Transformation!
Demo Script
The script needs access to the full environment of your current
shell, so it must be sourced instead of executed in a new process. To
try it out, be sure to set the permissions of the script to make it
executable and run . demo.sh
(or
source demo.sh
) to recreate your current environment in a
new shell.
#!/usr/bin/env bash
The _demo_load_env
function queries the current Bash
shell environment and outputs configuration commands that are to be
executed in the new Bash shell environment.
Environment variables are queried using the declare -p
command. This command ostensibly prints commands that can be evaluated
to set the same variables. The values may contain newlines, however,
which makes the printed commands invalid. This implementation processes
the output and transforms the commands for environment variables with
values that contain newlines to use escape-quoted ($'...'
)
syntax.
The output of declare -p
is processed one line at a
time, and any line starting with declare
and a space is
treated as the start of a declare
command. Note that values
that contain declare
and a space after a newline are
incorrectly treated as the start of a declare
command.
Fixing this issue requires matching quotations, which is difficult to do
in Bash. (I will likely resolve this issue in the future, but it is not
a priority at this time since such a value is unlikely to be used
outside of tests.) This is a good example of how the limitations of Bash
result in poor implementations. (See Rewrite When More
Than N Lines.)
To transform the commands for environment variables with values that contain newlines the value is processed as follows:
- Double quotes are unescaped.
- Backslashes are escaped.
- Single quotes are escaped.
- A trailing double quote is transformed to a single quote. If an additional value line is found, it is transformed back to a double quote.
- The additional value line is appended to the current definition, after an escaped newline.
Note that some environment variables that should not be copied are filtered from the output.
This function prints the commands to STDOUT
so that they
can be easily loaded into an array using readarray
below.
_demo_load_env () {
local defcmd defn line quoteflag value var
while IFS=$'\n' read -r line ; do
if [[ "${line}" =~ ^declare ]] ; then
if [ -n "${defn}" ] ; then
echo "${defn}"
defn=""
fi
defcmd="${line#declare -* }"
var="${defcmd%%=*}"
case "${var}" in
BASH_* | FUNCNAME | GROUPS | cmd | val )
;;
DEMO_ENV | defcmd | defn | line | quoteflag | value | var )
;;
* )
defn="${line}"
;;
esac
else
if [[ "${defn}" =~ ^[^=]*=\$ ]] ; then
if [ "${quoteflag}" -eq 1 ] ; then
defn="${defn%"'"}\""
fi
else
value="${defn#*'"'}"
value="${value//\\'"'/'"'}"
value="${value//\\/\\\\}"
value="${value//"'"/\\"'"}"
defn="${defn%%=*}=\$'${value}"
fi
line="${line//\\'"'/'"'}"
line="${line//\\/\\\\}"
line="${line//"'"/\\"'"}"
if [ "${line: -1}" == "\"" ] ; then
line="${line%?}'"
quoteflag=1
else
quoteflag=0
fi
defn="${defn}\\n${line}"
fi
done < <(declare -p)
test -z "${defn}" || echo "${defn}"
alias -p
}
When sourced, the current environment configuration is loaded into an
array. A new Bash shell process is executed using the script for
initialization. The current environment configuration is serialized and
passed to the new process using the DEMO_ENV_SER
environment variable.
When the new shell process exits (presumably when the user runs
exit
), the remaining environment variable is removed and
the source
command is exited. (Note that it would be
preferable to exit with the exit status of the new shell process, but I
do not know of a good way to do so in this case.)
if [ -z "${DEMO_ENV_SER}" ] ; then
if [ "${BASH_SOURCE[0]}" == "${0}" ] ; then
echo "usage: . ${0}" >&2
exit 2
fi
declare -a DEMO_ENV
readarray -t DEMO_ENV < <(_demo_load_env)
unset -f _demo_load_env
/usr/bin/env \
"$(declare -p DEMO_ENV)" \
DEMO_ENV_SER=--init-file "${BASH_SOURCE[0]}"
bash
unset DEMO_ENV
return 0
fi
The rest of the script is executed during initialization of the new
Bash shell process. The _demo_load_env
function is no
longer needed, so it is unset.
unset -f _demo_load_env
The _demo_restore_env
function loops through
configuration commands and determines which ones should be evaluated. It
filters out environment variable declarations for which the environment
variable already exists and is read-only.
Like _demo_load_env
, this function prints to
STDOUT
.
local defcmd envcmd var
for rstcmd in "${DEMO_ENV[@]}" ; do
if [[ "${rstcmd}" =~ ^declare ]] ; then
defcmd="${rstcmd#declare -* }"
var="${defcmd%%=*}"
envcmd="$(declare -p "${var}" 2>/dev/null)"
if [[ -z "${envcmd}" || "${envcmd}" =~ ^declare\ -[^r\ ]*\ ]] ; then
echo "${rstcmd}"
fi
else
echo "${rstcmd}"
fi
done
The new Bash shell is initialized by evaluating the serialized
configuration commands, restoring the array, and then evaluating the
commands selected by the _demo_restore_env
function. Note
that this evaluation cannot be done within a function, where the
declarations would create variables local to the function. Finally, the
_demo_restore_env
function and implementation environment
variables are unset.
eval "${DEMO_ENV_SER}"
while IFS=$'\n' read -r rstcmd ; do
eval "${rstcmd}"
done < <(_demo_restore_env)
unset -f _demo_restore_env
unset DEMO_ENV DEMO_ENV_SER rstcmd
The following is a simple test of this demonstration script:
$ echo "Current shell ID: $$"
Current shell ID: 403
$ TEST_VAR=$'one\n"two\'\tthree"\nfour'
$ declare -p TEST_VAR
declare -- TEST_VAR="one
\"two' three\"
four"
$ bash
$ echo "New shell ID: $$"
New shell ID: 405
$ echo "New shell does not have TEST_VAR: ${TEST_VAR}"
New shell does not have TEST_VAR:
$ exit
exit
$ echo "Back to current shell: $$"
Back to current shell: 403
$ . demo.sh
$ echo "New shell ID: $$"
New shell ID: 412
$ echo "New shell has TEST_VAR: ${TEST_VAR}"
New shell has TEST_VAR: one
"two' three"
four