diff --git a/.templates/00-global_var.sh b/.templates/00-global_var.sh index 17fa18ee50..711c65c82d 100755 --- a/.templates/00-global_var.sh +++ b/.templates/00-global_var.sh @@ -3,6 +3,160 @@ set -e +################################################################################ +# Block markers, temp helper, quoting and export-block builders +# +# Everything here is free of side effects and defined before the Supervisor +# guard, so that the self-test below can exercise the whole value path outside a +# container, where bashio is not available. +################################################################################ +BLOCK_BEGIN="# --- BEGIN ADDON ENV (generated) ---" +BLOCK_END="# --- END ADDON ENV (generated) ---" + +mktemp_safe() { + local tmpdir="${TMPDIR:-/tmp}" + mkdir -p "$tmpdir" + mktemp "$tmpdir/tmp.XXXXXXXXXX" +} + +dotenv_quote() { + # For /.env and /etc/environment: double quotes + minimal escaping. + # + # These files are read back by sourcing them from a shell, so every + # character that is still special inside double quotes has to be escaped. + # $ and ` used to be left alone, which meant a value was expanded instead of + # being read literally: a password like pa$$w0rd came back with the shell + # PID spliced into it, and a value containing backticks ran as a command. + # + # Backslash must be doubled first, so that the backslashes added below are + # not doubled in turn. + local v="$1" + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + v="${v//\$/\\\$}" + v="${v//\`/\\\`}" + v="${v//$'\n'/\\n}" + v="${v//$'\r'/\\r}" + printf '"%s"' "$v" +} + +shell_quote() { + # Single-quote for safe injection into shell code. + # + # Inside single quotes every character is literal, so the only thing a value + # needs escaping for is the quote character itself: close the quote, emit an + # escaped quote, reopen it. Backslashes must be left untouched. + # + # This used to double every backslash and to replace ' with '"'"' followed by + # a stray space. The stray space corrupted every value containing a quote + # (O'Brien pass arrived as O' Brien pass); the doubling was undone further + # down the path by the "awk -v" in append_export, so backslashes survived by + # accident. Both halves are fixed together -- see the note in append_export. + local s="$1" + printf "'%s'" "${s//\'/\'\\\'\'}" +} + +append_export() { + # Plain append, deliberately not awk: "awk -v q=$value" runs the value + # through awk's escape processing, which turns \t into a tab, \b into a + # backspace and \\ into a single backslash. That used to be cancelled out by + # shell_quote doubling every backslash, so the two bugs hid each other -- + # fixing only one of them corrupts the value. + printf 'export %s=%s\n' "$1" "$(shell_quote "$2")" >> "$EXPORT_BODY" +} + +compose_export_block() { + { + echo "$BLOCK_BEGIN" + echo "# Generated from $JSONSOURCE" + cat "$EXPORT_BODY" + echo "$BLOCK_END" + } > "$EXPORT_BLOCK" +} + +################################################################################ +# Self-test: bash .templates/00-global_var.sh --self-test +# +# Builds a real export block and sources it, which is exactly what happens once +# the block is injected at the top of a service run script, then checks that +# every value came back byte for byte. Testing the whole path matters: the two +# defects this guards against (shell_quote doubling backslashes and append_export +# passing values through "awk -v") cancelled each other out, so a test of either +# helper alone reported success while the pair was wrong. +# +# Runs before the Supervisor guard and exits, so it never affects startup. +################################################################################ +if [[ "${1:-}" == "--self-test" ]]; then + # Literal test data: the single quotes and metacharacters are the point. + # shellcheck disable=SC2016 + self_test_values=( + 'plain.host' + 'next\.duckdns\.org' # regex, dots escaped once + 'next\\.duckdns\\.org' # regex, dots escaped twice by the user + 'C:\Users\bob\share' # windows path, \U and \b are awk escapes + '\\server\share' # UNC path + 'col\tsep' # \t is an awk escape + "O'Brien pass" # embedded quote + "it's a 'quoted' word" # several embedded quotes + "'leading" + "trailing'" + 'a$b`c"d' # shell metacharacters + '*.example.com|^foo\d+$' + $'sp ace\ttab' + '' + ) + + JSONSOURCE="self-test" + EXPORT_BODY="$(mktemp_safe)" + EXPORT_BLOCK="$(mktemp_safe)" + self_test_env="$(mktemp_safe)" + trap 'rm -f "$EXPORT_BODY" "$EXPORT_BLOCK" "$self_test_env"' EXIT + self_test_rc=0 + + self_test_check() { + # $1 name of the variable that was read back, $2 expected value, $3 how + local self_test_got="${!1}" + [[ "$self_test_got" == "$2" ]] && return 0 + printf 'FAIL (%s): <%s> came back as <%s>\n' "$3" "$2" "$self_test_got" + self_test_rc=1 + } + + # 1. The export block, sourced the way an injected run script would + for self_test_i in "${!self_test_values[@]}"; do + append_export "SELFTEST_${self_test_i}" "${self_test_values[$self_test_i]}" + done + compose_export_block + # shellcheck source=/dev/null + . "$EXPORT_BLOCK" + for self_test_i in "${!self_test_values[@]}"; do + self_test_check "SELFTEST_${self_test_i}" "${self_test_values[$self_test_i]}" "export block" + done + + # 2. /.env, sourced the way browserless_chrome and wger read it back. + # Values holding a newline are out of scope: dotenv_quote writes them as a + # literal \n, which a dotenv parser unescapes but a shell does not. + for self_test_i in "${!self_test_values[@]}"; do + printf 'DOTENVTEST_%s=%s\n' \ + "$self_test_i" "$(dotenv_quote "${self_test_values[$self_test_i]}")" + done > "$self_test_env" + if ! bash -n "$self_test_env"; then + # An unescaped backtick or quote leaves the file unparseable, which would + # abort the sourcing shell instead of just yielding a wrong value. + echo "FAIL (dotenv): generated env file is not valid shell" + self_test_rc=1 + else + # shellcheck source=/dev/null + . "$self_test_env" + for self_test_i in "${!self_test_values[@]}"; do + self_test_check "DOTENVTEST_${self_test_i}" "${self_test_values[$self_test_i]}" "dotenv" + done + fi + + [[ "$self_test_rc" -eq 0 ]] && + echo "${#self_test_values[@]} values round-tripped unchanged (export block + dotenv)" + exit "$self_test_rc" +fi + ################################################################################ # Guard: only run inside Supervisor-managed add-ons ################################################################################ @@ -30,15 +184,6 @@ command -v jq >/dev/null || bashio::exit.nok "jq is required" mkdir -p /etc touch "$ETC_ENV_FILE" -################################################################################ -# Temp helper -################################################################################ -mktemp_safe() { - local tmpdir="${TMPDIR:-/tmp}" - mkdir -p "$tmpdir" - mktemp "$tmpdir/tmp.XXXXXXXXXX" -} - ################################################################################ # Secrets support ################################################################################ @@ -68,54 +213,13 @@ resolve_secret() { printf '%s' "$line" } -################################################################################ -# Quoting -################################################################################ -dotenv_quote() { - # For /.env and /etc/environment: double quotes + minimal escaping - local v="$1" - v="${v//\\/\\\\}" - v="${v//\"/\\\"}" - v="${v//$'\n'/\\n}" - v="${v//$'\r'/\\r}" - printf '"%s"' "$v" -} - -shell_quote() { - # Single-quote for safe injection in shell code - local s="$1" - s="${s//\\/\\\\}" - s="${s//\'/\'\"\'\"\' }" - s="${s% }" - printf "'%s'" "$s" -} - ################################################################################ # S6 + script injection block ################################################################################ -BLOCK_BEGIN="# --- BEGIN ADDON ENV (generated) ---" -BLOCK_END="# --- END ADDON ENV (generated) ---" - EXPORT_BLOCK="$(mktemp_safe)" +EXPORT_BODY="$(mktemp_safe)" KV_FILE="$(mktemp_safe)" -trap 'rm -f "$EXPORT_BLOCK" "$KV_FILE"' EXIT - -{ - echo "$BLOCK_BEGIN" - echo "# Generated from $JSONSOURCE" - echo "$BLOCK_END" -} > "$EXPORT_BLOCK" - -append_export() { - local k="$1" v="$2" q - q="$(shell_quote "$v")" - - awk -v k="$k" -v q="$q" -v e="$BLOCK_END" ' - $0==e { print "export " k "=" q } - { print } - ' "$EXPORT_BLOCK" > "$EXPORT_BLOCK.tmp" - mv "$EXPORT_BLOCK.tmp" "$EXPORT_BLOCK" -} +trap 'rm -f "$EXPORT_BLOCK" "$EXPORT_BODY" "$KV_FILE"' EXIT inject_block() { local f="$1" tmp @@ -235,6 +339,8 @@ cp "$ENV_FILE" "$ETC_ENV_FILE" ################################################################################ # Inject into scripts and shells (best-effort) ################################################################################ +compose_export_block + for f in /etc/services.d/*/run /etc/s6-overlay/s6-rc.d/*/run /etc/cont-init.d/*.sh /entrypoint.sh /etc/bash.bashrc "${GLOBAL_VAR_FILES:-}"; do [[ -f "$f" ]] && inject_block "$f" done