fix(dotenv): implement secure parsing for .env files and add comprehensive tests

This commit is contained in:
Marc Cornellà 2026-03-03 20:28:22 +01:00
commit 2014363332
10 changed files with 850 additions and 1 deletions

View file

@ -0,0 +1,139 @@
#!/usr/bin/env zsh
# Bootstrap script for dotenv plugin tests
# This is sourced before any tests run and provides shared utilities
# Load the dotenv plugin
source "$PWD/dotenv.plugin.zsh"
ZSH_DOTENV_PROMPT=false
ZSH_DOTENV_FILE=/dev/null
# Helper: Parse dotenv file in test mode
_parse_dotenv_test() {
parse_dotenv "$1" "test"
}
# Helper: Parse dotenv file in export mode
_parse_dotenv_export() {
unset "${(k)parameters[(R)*export*]}" 2>/dev/null || true
parse_dotenv "$1" "test"
for key in "${(k)DOTENV_TEST_VARS}"; do
typeset -x "$key"="${DOTENV_TEST_VARS[$key]}"
done
}
# Helper: Run parse_dotenv supressing stderr
_parse_dotenv_quiet() {
parse_dotenv "$@" 2>/dev/null
}
# Helper: Create a temporary test fixture
_create_temp_fixture() {
local fixture
fixture==(:) # Create temp file
echo "$fixture"
}
_write_temp_fixture() {
local fixture="$1"
> "$fixture"
}
# Helper: Source file with allexport and capture variables
# Usage: _source_with_allexport "file.env"
# Result is in DOTENV_SOURCE_VARS associative array
_source_with_allexport() {
local filename="$1"
# Source with allexport in a subshell with no exported variables
# The return and capture of the exported variables is a bit of a pain:
# 1. We first store the key=value pairs in $vars associative array, which is
# defined before allexport is set to avoid appearing in results.
# 2. Afterwards, we join all keys and values of the associative with null delimiters. With
# "$(@kv)vars}" we get keys and values with quotes, to retain empty values. With (pj:\0:)
# we join them with nulls.
# 3. The caller reads this output with "${(@0)}" to split by nulls and quoting to retain
# empty values, and then uses it to populate an associative array.
# Don't try to understand this or change it unless you have to. Debugging is a nightmare.
typeset -gA DOTENV_SOURCE_VARS
DOTENV_SOURCE_VARS=("${(@0)"$(
local -A vars
# Clear all exports first
zmodload zsh/parameter
unset ${(k)parameters[(R)*export*]} 2>/dev/null || true
# Source file with allexport
setopt localoptions allexport
source "$filename"
# Set all exported variables into an associative array
for key in ${(k)parameters[(R)*export*]}; do
vars[$key]="${(P)key}"
done
print -rn -- "${(@kvpj:\0:)vars}"
)"}")
}
## ZUnit assertion helpers
_zunit_assert_function_exists() {
[[ "${+functions[$1]}" -eq 1 ]] && return 0
echo "Function '$1' does not exist"
exit 1
}
_zunit_assert_var_same_as() {
local tvalue=${${:-${(Pt)1%-*}}:-unset} tcomp=${${:-${(Pt)2%-*}}:-unset}
if [[ $tvalue != $tcomp ]]; then
echo "Type mismatch: '$1' ($tvalue) and '$2' ($tcomp)"
exit 78
fi
# Special case for associative arrays
if [[ ${(Pt)1} == "association" ]]; then
local -A value=("${(P@kv)1}") comparison=("${(P@kv)2}")
local -aU keys=("${(@k)value}" "${(@k)comparison}")
local ret=0 key
for key in "${keys[@]}"; do
# Key match checks
if [[ -v "value[$key]" && ! -v "comparison[$key]" ]]; then
echo "'$1[$key]' is set"
ret=1
elif [[ ! -v "value[$key]" && -v "comparison[$key]" ]]; then
echo "'$1[$key]' is not set"
ret=1
# Value match checks
elif [[ "${value[$key]}" != "${comparison[$key]}" ]]; then
echo "'$1[$key]' value mismatch: '${value[$key]}' is not the same as '${comparison[$key]}'"
ret=1
fi
done
exit $ret
fi
# Generic case
local value="${(P)1}" comparison="${(P)2}"
[[ "$value" != "$comparison" ]] || exit 0
echo "'$1' value mismatch: '$value' is not the same as '$comparison'"
exit 1
}
_zunit_assert_var_is_set() {
[[ -v "$1" ]] && return 0
echo "Variable '$1' is not set"
exit 1
}
_zunit_assert_var_is_not_set() {
[[ ! -v "$1" ]] && return 0
echo "Variable '$1' is set"
exit 1
}

View file

@ -0,0 +1,88 @@
# Consolidated dotenv test fixture from dotenv test suite
# Source: https://github.com/motdotla/dotenv/tree/master/tests
#
# Copyright (c) 2015, Scott Motte
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# Basic assignments
BASIC=basic
# previous line intentionally left blank
AFTER_LINE=after_line
# Empty values
EMPTY=
EMPTY_SINGLE_QUOTES=''
EMPTY_DOUBLE_QUOTES=""
# Single quotes (literal, no expansion)
SINGLE_QUOTES='single_quotes'
SINGLE_QUOTES_SPACED=' single quotes '
DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
# Double quotes (with escapes)
DOUBLE_QUOTES="double_quotes"
DOUBLE_QUOTES_SPACED=" double quotes "
EXPAND_NEWLINES="expand\nnew\nlines"
# Unquoted (no escape expansion)
DONT_EXPAND_UNQUOTED=dontexpand\nnewlines
# Quotes inside quotes
DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes'
SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes"
# Comments
# COMMENTS=work
INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work
INLINE_COMMENTS_UNQUOTED=value # work
# Special characters
EQUAL_SIGNS=equals==
RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
USEREMAIL=therealnerdybeast@example.tld
# Multiline values with double quotes
MULTI_DOUBLE_QUOTED="THIS
IS
A
MULTILINE
STRING"
# Multiline values with single quotes
MULTI_SINGLE_QUOTED='THIS
IS
A
MULTILINE
STRING'
# Multiline PEM certificate
MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u
LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/
bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/
kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V
u4QuUoobAgMBAAE=
-----END PUBLIC KEY-----"

View file

@ -0,0 +1,23 @@
# Export syntax
export EXPORTED_VAR=exported_value
export EXPORTED_EMPTY=
# Variable expansion (in-file forward references)
BASE_URL=https://api.example.com
API_ENDPOINT="${BASE_URL}/v1"
FULL_ENDPOINT=$BASE_URL/v2/users
COMBINED="${BASE_URL}_suffix"
# Testing multiline quoting edge cases
MULTILINE_UNQUOTED=This\ is\ a\ \
multiline\ value\ that\ should\ be\ treated\ as\ a\ single\ line\ with\ a\ literal\ backslash\ and\ newline
MULTILINE_DOUBLE_QUOTED="This is a \
multiline value that should be treated as a single line with an actual newline character"
MULTILINE_SINGLE_QUOTED='This is a \
multiline value that should be treated as a single line with a literal backslash and newline'
MULTILINE_MIXED_QUOTES="This is a \
multiline value that should be treated as a single line with an actual newline character and a literal backslash \"and 'single quotes' inside"
# Test for regressions
DATABASE_URL="postgres://user:pass@host/db;sslmode=require"
VAR_WITH_SEMICOLONS="value ; with ; semicolons"