d170d18746bb06db7b2fc97b67e281597a3fc152
- Author
- Marc Cornellà <marc@mcornella.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/plugins/dotenv/.zunit.yml b/plugins/dotenv/.zunit.yml
2new file mode 100644
3index 0000000000000000000000000000000000000000..e5ea0c3a6d5cc14adfcbb1f902720db8597f0a8b
4--- /dev/null
5+++ b/plugins/dotenv/.zunit.yml
6@@ -0,0 +1,9 @@
7+tap: false
8+directories:
9+ tests: tests
10+ output: tests/_output
11+ support: tests/_support
12+time_limit: 0
13+fail_fast: false
14+allow_risky: false
15+verbose: false
16diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md
17index 5dbcf0fb1446bbd039038cc1390c614bc6015682..8b3f9ecce3265abc489748246b7f49623716f16b 100644
18--- a/plugins/dotenv/README.md
19+++ b/plugins/dotenv/README.md
20@@ -34,6 +34,25 @@ PORT=3001
21
22 You can even mix both formats, although it's probably a bad idea.
23
24+Multi-line values are supported using quoted strings:
25+
26+```sh
27+PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
28+MIIEowIBAAKCAQEA...
29+-----END RSA PRIVATE KEY-----"
30+```
31+
32+Variables defined earlier in the file can be referenced by later entries:
33+
34+```sh
35+BASE_URL=https://example.com
36+API_URL=$BASE_URL/api
37+ASSETS_URL=${BASE_URL}/assets
38+```
39+
40+Note: only variables defined within the same `.env` file are expanded this way —
41+shell environment variables that already exist are **not** substituted.
42+
43 ## Settings
44
45 ### ZSH_DOTENV_FILE
46@@ -86,13 +105,37 @@ mount `.env` files as named pipes to inject secrets on-the-fly without writing t
47
48 No additional configuration is required — the plugin automatically detects and sources named pipes.
49
50+## Tests
51+
52+The tests use [zunit](https://github.com/zunit-zsh/zunit). Install it per its [documentation](https://github.com/zunit-zsh/zunit#installation), then run:
53+
54+```sh
55+cd plugins/dotenv && zunit
56+```
57+
58+> [NOTE!]
59+> zunit also requires installing [Revolver](https://github.com/molovo/revolver).
60+
61 ## Version Control
62
63 **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.
64
65-## Disclaimer
66+## Security
67+
68+The plugin applies several best-effort safeguards when loading a `.env` file:
69+
70+- **Size limit** — files larger than 10 MiB are rejected to prevent DoS.
71+- **Syntax check** — the file is validated with `zsh -fn` before any variables are set.
72+- **No command substitution** — entries containing `$(...)` or backtick constructs are skipped.
73+- **Forbidden variables** — the following variables are never overwritten, regardless of what the
74+ `.env` file contains: `NODE_OPTIONS`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `ZSH`, `LD_PRELOAD`,
75+ `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `GIT_CONFIG_GLOBAL`, `GIT_DIR`, `GIT_EDITOR`,
76+ `GIT_EXTERNAL_DIFF`, `GIT_EXEC_PATH`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`,
77+ `GIT_SSL_NO_VERIFY`, `GIT_TEMPLATE_DIR`, `VISUAL`, `PAGER`, `EDITOR`, and all zsh special
78+ parameters.
79
80diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh
81index c44c369b56d0825de08a2cc6ff430077a59c4f60..72839a501c81022702c3b1a66432793f5628469d 100644
82--- a/plugins/dotenv/dotenv.plugin.zsh
83+++ b/plugins/dotenv/dotenv.plugin.zsh
84@@ -7,9 +7,271 @@
85 : ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"}
86 : ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"}
87
88-
89 ## Functions
90
91+_parse_dotenv_content() {
92+ setopt localoptions extendedglob
93+
94+ local content="$1"
95+ local mode="${2:-export}"
96+
97+ # Validate mode argument
98+ case "$mode" in
99+ export|test) ;;
100+ *)
101+ echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2
102+ return 1
103+ ;;
104+ esac
105+
106+ local node line key value
107+ local raw_value expanded prefix remainder var_name escaped_dollar_placeholder
108+ local sq dq uq safe
109+ local -A parsed_vars
110+ local -a nodes lines
111+
112+ # Parse into command lines separated by `;`, with built-in support for multi-line commands.
113+ # (Z:C:) ignores comments and preserves quotes and escapes.
114+ #
115+ # All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines
116+ # by joining all elements between ';'.
117+ #
118+ # Example input:
119+ # VAR1=value1; VAR2=value2
120+ # VAR3="multi
121+ # line value"
122+ # Result:
123+ # typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' )
124+ # typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' )
125+ #
126+ nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command
127+ for node in "${nodes[@]}"; do
128+ if [[ "$node" == ";" ]]; then
129+ if [[ -n "$line" ]]; then
130+ lines+=("$line")
131+ line=""
132+ fi
133+ continue
134+ fi
135+
136+ [[ -z "$line" ]] || line+=" "
137+ line+="$node"
138+ done
139+
140+ local -a forbidden_vars=(
141+ NODE_OPTIONS
142+ BASH_ENV
143+ ENV
144+ ZDOTDIR
145+ ZSH
146+ LD_PRELOAD
147+ LD_LIBRARY_PATH
148+ DYLD_INSERT_LIBRARIES
149+ GIT_CONFIG_GLOBAL
150+ GIT_DIR
151+ GIT_EDITOR
152+ GIT_EXTERNAL_DIFF
153+ GIT_EXEC_PATH
154+ GIT_PAGER
155+ GIT_SSH
156+ GIT_SSH_COMMAND
157+ GIT_SSL_NO_VERIFY
158+ GIT_TEMPLATE_DIR
159+ VISUAL
160+ PAGER
161+ EDITOR
162+ ${(k)parameters[(R)*export*special]}
163+ )
164+ local forbidden="${(j:|:)forbidden_vars}"
165+
166+
167+ # Each line contains a single command line, we need to parse valid KEY=VALUE pairs
168+ for line in "${lines[@]}"; do
169+ # Strip leading 'export ' keyword
170+ line="${line#export[ ]}"
171+
172+ # Match KEY=VALUE pattern
173+ # "A name may be any sequence of alphanumeric characters and underscores"
174+ # https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters
175+ if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then
176+ continue
177+ fi
178+
179+ key="${match[1]}"
180+ value="${match[2]}"
181+ raw_value="$value"
182+
183+ # Filter out variables to be ignored for security reasons (best effort)
184diff --git a/plugins/dotenv/tests/_output/.gitignore b/plugins/dotenv/tests/_output/.gitignore
185new file mode 100644
186index 0000000000000000000000000000000000000000..d6b7ef32c8478a48c3994dcadc86837f4371184d
187--- /dev/null
188+++ b/plugins/dotenv/tests/_output/.gitignore
189@@ -0,0 +1,2 @@
190+*
191+!.gitignore
192diff --git a/plugins/dotenv/tests/_support/bootstrap b/plugins/dotenv/tests/_support/bootstrap
193new file mode 100644
194index 0000000000000000000000000000000000000000..f45bec020fba8b72d638856c9dd747148e7ef035
195--- /dev/null
196+++ b/plugins/dotenv/tests/_support/bootstrap
197@@ -0,0 +1,139 @@
198+#!/usr/bin/env zsh
199+# Bootstrap script for dotenv plugin tests
200+# This is sourced before any tests run and provides shared utilities
201+
202+# Load the dotenv plugin
203+source "$PWD/dotenv.plugin.zsh"
204+ZSH_DOTENV_PROMPT=false
205+ZSH_DOTENV_FILE=/dev/null
206+
207+# Helper: Parse dotenv file in test mode
208+_parse_dotenv_test() {
209+ parse_dotenv "$1" "test"
210+}
211+
212+# Helper: Parse dotenv file in export mode
213+_parse_dotenv_export() {
214+ unset "${(k)parameters[(R)*export*]}" 2>/dev/null || true
215+
216+ parse_dotenv "$1" "test"
217+
218+ for key in "${(k)DOTENV_TEST_VARS}"; do
219+ typeset -x "$key"="${DOTENV_TEST_VARS[$key]}"
220+ done
221+}
222+
223+# Helper: Run parse_dotenv suppressing stderr
224+_parse_dotenv_quiet() {
225+ parse_dotenv "$@" 2>/dev/null
226+}
227+
228+# Helper: Create a temporary test fixture
229+_create_temp_fixture() {
230+ local fixture
231+ fixture==(:) # Create temp file
232+ echo "$fixture"
233+}
234+
235+_write_temp_fixture() {
236+ local fixture="$1"
237+ > "$fixture"
238+}
239+
240+
241+# Helper: Source file with allexport and capture variables
242+# Usage: _source_with_allexport "file.env"
243+# Result is in DOTENV_SOURCE_VARS associative array
244+_source_with_allexport() {
245+ local filename="$1"
246+
247+ # Source with allexport in a subshell with no exported variables
248+
249+ # The return and capture of the exported variables is a bit of a pain:
250+ # 1. We first store the key=value pairs in $vars associative array, which is
251+ # defined before allexport is set to avoid appearing in results.
252+ # 2. Afterwards, we join all keys and values of the associative with null delimiters. With
253+ # "$(@kv)vars}" we get keys and values with quotes, to retain empty values. With (pj:\0:)
254+ # we join them with nulls.
255+ # 3. The caller reads this output with "${(@0)}" to split by nulls and quoting to retain
256+ # empty values, and then uses it to populate an associative array.
257+ # Don't try to understand this or change it unless you have to. Debugging is a nightmare.
258+ typeset -gA DOTENV_SOURCE_VARS
259+ DOTENV_SOURCE_VARS=("${(@0)"$(
260+ local -A vars
261+
262+ # Clear all exports first
263+ zmodload zsh/parameter
264+ unset ${(k)parameters[(R)*export*]} 2>/dev/null || true
265+
266+ # Source file with allexport
267+ setopt localoptions allexport
268+ source "$filename"
269+
270+ # Set all exported variables into an associative array
271+ for key in ${(k)parameters[(R)*export*]}; do
272+ vars[$key]="${(P)key}"
273+ done
274+
275+ print -rn -- "${(@kvpj:\0:)vars}"
276+ )"}")
277+}
278+
279+
280+## ZUnit assertion helpers
281+
282+_zunit_assert_function_exists() {
283+ [[ "${+functions[$1]}" -eq 1 ]] && return 0
284+ echo "Function '$1' does not exist"
285+ exit 1
286+}
287+
288+_zunit_assert_var_same_as() {
289+ local tvalue=${${:-${(Pt)1%-*}}:-unset} tcomp=${${:-${(Pt)2%-*}}:-unset}
290+ if [[ $tvalue != $tcomp ]]; then
291+ echo "Type mismatch: '$1' ($tvalue) and '$2' ($tcomp)"
292+ exit 78
293+ fi
294+
295+ # Special case for associative arrays
296+ if [[ ${(Pt)1} == "association" ]]; then
297diff --git a/plugins/dotenv/tests/_support/fixtures/dotenvjs.env b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env
298new file mode 100644
299index 0000000000000000000000000000000000000000..16a56267c417952ae35676bd0e662bc17a7962c9
300--- /dev/null
301+++ b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env
302@@ -0,0 +1,88 @@
303+# Consolidated dotenv test fixture from dotenv test suite
304+# Source: https://github.com/motdotla/dotenv/tree/master/tests
305+#
306+# Copyright (c) 2015, Scott Motte
307+# All rights reserved.
308+
309+# Redistribution and use in source and binary forms, with or without
310+# modification, are permitted provided that the following conditions are met:
311+
312+# * Redistributions of source code must retain the above copyright notice, this
313+# list of conditions and the following disclaimer.
314+
315+# * Redistributions in binary form must reproduce the above copyright notice,
316+# this list of conditions and the following disclaimer in the documentation
317+# and/or other materials provided with the distribution.
318+
319+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
320+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
321+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
322+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
323+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
324+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
325+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
326+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
327+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
328+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
329+
330+# Basic assignments
331+BASIC=basic
332+
333+# previous line intentionally left blank
334+AFTER_LINE=after_line
335+
336+# Empty values
337+EMPTY=
338+EMPTY_SINGLE_QUOTES=''
339+EMPTY_DOUBLE_QUOTES=""
340+
341+# Single quotes (literal, no expansion)
342+SINGLE_QUOTES='single_quotes'
343+SINGLE_QUOTES_SPACED=' single quotes '
344+DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
345+
346+# Double quotes (with escapes)
347+DOUBLE_QUOTES="double_quotes"
348+DOUBLE_QUOTES_SPACED=" double quotes "
349+EXPAND_NEWLINES="expand\nnew\nlines"
350+
351+# Unquoted (no escape expansion)
352+DONT_EXPAND_UNQUOTED=dontexpand\nnewlines
353+
354+# Quotes inside quotes
355+DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes'
356+SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes"
357+
358+# Comments
359+# COMMENTS=work
360+INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
361+INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work
362+INLINE_COMMENTS_UNQUOTED=value # work
363+
364+# Special characters
365+EQUAL_SIGNS=equals==
366+RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
367+USEREMAIL=therealnerdybeast@example.tld
368+
369+# Multiline values with double quotes
370+MULTI_DOUBLE_QUOTED="THIS
371+IS
372+A
373+MULTILINE
374+STRING"
375+
376+# Multiline values with single quotes
377+MULTI_SINGLE_QUOTED='THIS
378+IS
379+A
380+MULTILINE
381+STRING'
382+
383+# Multiline PEM certificate
384+MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY-----
385+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u
386+LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/
387+bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/
388+kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V
389+u4QuUoobAgMBAAE=
390+-----END PUBLIC KEY-----"
391diff --git a/plugins/dotenv/tests/_support/fixtures/features.env b/plugins/dotenv/tests/_support/fixtures/features.env
392new file mode 100644
393index 0000000000000000000000000000000000000000..e5862bc8e7bda503fc02d2c80608bb440fc44bfa
394--- /dev/null
395+++ b/plugins/dotenv/tests/_support/fixtures/features.env
396@@ -0,0 +1,23 @@
397+# Export syntax
398+export EXPORTED_VAR=exported_value
399+export EXPORTED_EMPTY=
400+
401+# Variable expansion (in-file forward references)
402+BASE_URL=https://api.example.com
403+API_ENDPOINT="${BASE_URL}/v1"
404+FULL_ENDPOINT=$BASE_URL/v2/users
405+COMBINED="${BASE_URL}_suffix"
406+
407+# Testing multiline quoting edge cases
408+MULTILINE_UNQUOTED=This\ is\ a\ \
409+multiline\ value\ that\ should\ be\ treated\ as\ a\ single\ line\ with\ a\ literal\ backslash\ and\ newline
410+MULTILINE_DOUBLE_QUOTED="This is a \
411+multiline value that should be treated as a single line with an actual newline character"
412+MULTILINE_SINGLE_QUOTED='This is a \
413+multiline value that should be treated as a single line with a literal backslash and newline'
414+MULTILINE_MIXED_QUOTES="This is a \
415+multiline value that should be treated as a single line with an actual newline character and a literal backslash \"and 'single quotes' inside"
416+
417+# Test for regressions
418+DATABASE_URL="postgres://user:pass@host/db;sslmode=require"
419+VAR_WITH_SEMICOLONS="value ; with ; semicolons"
420diff --git a/plugins/dotenv/tests/basic-parsing.zunit b/plugins/dotenv/tests/basic-parsing.zunit
421new file mode 100644
422index 0000000000000000000000000000000000000000..611f6a70a57cc7a84d90f681b63d3a54b5391fb6
423--- /dev/null
424+++ b/plugins/dotenv/tests/basic-parsing.zunit
425@@ -0,0 +1,398 @@
426+#!/usr/bin/env zunit
427+
428+
429+@setup {
430+ typeset -g fixture="$(_create_temp_fixture)"
431+ typeset -gA expected_vars=()
432+}
433+
434+@teardown {
435+ [[ -f "$fixture" ]] && command rm -f "$fixture"
436+ unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
437+}
438+
439+@test 'dotenv plugin loads successfully' {
440+ assert "parse_dotenv" function_exists
441+ assert "source_env" function_exists
442+}
443+
444+@test 'parse returns error for unsupported mode' {
445+ run _parse_dotenv_quiet "/dev/null" "export"
446+ assert $state equals 0
447+
448+ run _parse_dotenv_quiet "/dev/null" "test"
449+ assert $state equals 0
450+
451+ run _parse_dotenv_quiet "/dev/null" "invalid_mode"
452+ assert $state equals 1
453+}
454+
455+@test 'parse returns error for oversized file (> 10MiB)' {
456+ command truncate -s 11M "$fixture" 2>/dev/null
457+
458+ run _parse_dotenv_quiet "$fixture" "test"
459+ assert $state equals 1
460+}
461+
462+@test 'parse returns error for non-existent file' {
463+ run _parse_dotenv_quiet "/nonexistent/path/.env" "test"
464+ assert $state equals 1
465+}
466+
467+@test 'source_env loads named pipes without blocking' {
468+ local tmpdir fifo output result
469+ local child_pid writer_pid killer_pid child_rc
470+
471+ tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")"
472+ fifo="$tmpdir/.env"
473+ output="$tmpdir/output"
474+ command mkfifo "$fifo"
475+
476+ (
477+ print -r -- 'TOKEN=secret' > "$fifo"
478+ ) &
479+ writer_pid=$!
480+
481+ (
482+ ZSH_DOTENV_PROMPT=false
483+ ZSH_DOTENV_FILE="$fifo"
484+ source_env
485+ print -r -- "${TOKEN-<unset>}" > "$output"
486+ ) &
487+ child_pid=$!
488+
489+ (
490+ sleep 2
491+ kill -0 $child_pid 2>/dev/null || exit 0
492+ kill $child_pid 2>/dev/null || exit 0
493+ ) &
494+ killer_pid=$!
495+
496+ wait $child_pid
497+ child_rc=$?
498+
499+ kill $killer_pid 2>/dev/null || true
500+ kill $writer_pid 2>/dev/null || true
501+ wait $writer_pid 2>/dev/null || true
502+
503+ [[ -f "$output" ]] && result="$(<"$output")"
504+ command rm -rf "$tmpdir"
505+
506+ assert $child_rc equals 0
507+ assert "$result" equals 'secret'
508+}
509+
510+@test 'source_env rejects oversized named pipes' {
511+ run zsh -fc '
512+ source ./dotenv.plugin.zsh
513+
514+ tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" || exit 1
515+ fifo="$tmpdir/.env"
516+ command mkfifo "$fifo" || exit 1
517+
518+ cleanup() {
519+ kill $killer_pid 2>/dev/null || true
520+ kill $writer_pid 2>/dev/null || true
521+ wait $writer_pid 2>/dev/null || true
522+ command rm -rf "$tmpdir"
523+ }
524+ trap cleanup EXIT
525diff --git a/plugins/dotenv/tests/compatibility.zunit b/plugins/dotenv/tests/compatibility.zunit
526new file mode 100644
527index 0000000000000000000000000000000000000000..61c5dddba3355c12ea996652d922e879728ac3b6
528--- /dev/null
529+++ b/plugins/dotenv/tests/compatibility.zunit
530@@ -0,0 +1,27 @@
531+#!/usr/bin/env zunit
532+
533+@setup {
534+ unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
535+}
536+
537+@teardown {
538+ unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
539+}
540+
541+@test 'compatibility: dotenvjs fixture matches native source' {
542+ local fixture="${testdir:A}/_support/fixtures/dotenvjs.env"
543+
544+ _parse_dotenv_test "$fixture"
545+ _source_with_allexport "$fixture"
546+
547+ assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS"
548+}
549+
550+@test 'compatibility: features fixture matches native source' {
551+ local fixture="${testdir:A}/_support/fixtures/features.env"
552+
553+ _parse_dotenv_test "$fixture"
554+ _source_with_allexport "$fixture"
555+
556+ assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS"
557+}
558diff --git a/plugins/dotenv/tests/security.zunit b/plugins/dotenv/tests/security.zunit
559new file mode 100644
560index 0000000000000000000000000000000000000000..414f87bb7209870444c460193cd250c1d0caf32d
561--- /dev/null
562+++ b/plugins/dotenv/tests/security.zunit
563@@ -0,0 +1,209 @@
564+#!/usr/bin/env zunit
565+
566+@setup {
567+ typeset -g fixture="$(_create_temp_fixture)"
568+ typeset -gA expected_vars=()
569+}
570+
571+@teardown {
572+ [[ -f "$fixture" ]] && command rm -f "$fixture"
573+ unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null
574+}
575+
576+@test 'skip dangerous backtick command substitution' {
577+ > "$fixture" <<'EOF'
578+# Should be skipped
579+DANGEROUS_BACKTICK=`whoami`
580+EOF
581+
582+ _parse_dotenv_test "$fixture"
583+
584+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
585+}
586+
587+@test 'skip dangerous subshell command substitution' {
588+ > "$fixture" <<'EOF'
589+# Should be skipped
590+DANGEROUS_SUBSHELL=$(date)
591+EOF
592+
593+ _parse_dotenv_test "$fixture"
594+
595+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
596+}
597+
598+@test 'skip nested command substitution in double quotes' {
599+ > "$fixture" <<'EOF'
600+# Should be skipped
601+DANGEROUS_NESTED="prefix_$(echo malicious)_suffix"
602+EOF
603+
604+ _parse_dotenv_test "$fixture"
605+
606+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
607+}
608+
609+@test 'skip multiple words (potential command execution)' {
610+ > "$fixture" <<'EOF'
611+# Should be skipped - multiple words could execute commands
612+BASE_URL=/ echo command run
613+EOF
614+
615+ _parse_dotenv_test "$fixture"
616+
617+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
618+}
619+
620+@test 'allow literal command substitution in single quotes' {
621+ > "$fixture" <<'EOF'
622+# Single quotes make everything literal - should be parsed
623+SAFE_SINGLE_QUOTED='$(this is literal)'
624+SAFE_BACKTICK='`also literal`'
625+
626+# Should also be parsed
627+SAFE_VAR=safe_value
628+EOF
629+
630+ expected_vars=(
631+ SAFE_SINGLE_QUOTED '$(this is literal)'
632+ SAFE_BACKTICK '`also literal`'
633+ SAFE_VAR 'safe_value'
634+ )
635+
636+ _parse_dotenv_test "$fixture"
637+
638+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
639+}
640+
641+@test 'skip backticks in unquoted values' {
642+ > "$fixture" <<'EOF'
643+# Backticks in unquoted context - should be skipped
644+DANGEROUS_UNQUOTED=`echo danger`
645+EOF
646+
647+ _parse_dotenv_test "$fixture"
648+
649+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
650+}
651+
652+@test 'skip dollar-paren in unquoted values' {
653+ > "$fixture" <<'EOF'
654+# Command substitution in unquoted context - should be skipped
655+DANGEROUS_UNQUOTED=$(uname -a)
656+EOF
657+
658+ _parse_dotenv_test "$fixture"
659+
660+ assert "DOTENV_TEST_VARS" var_same_as "expected_vars"
661+}
662+