Merge pull request #2907 from alexbelgium/claude/elegant-nobel-8ca7b6

fix(templates): stop corrupting option values in shell_quote / dotenv_quote
This commit is contained in:
Alexandre
2026-07-27 12:21:25 +02:00
committed by GitHub

View File

@@ -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