dotenv.plugin.zsh
9567 bytes
1## Settings
2
3# Filename of the dotenv file to look for
4: ${ZSH_DOTENV_FILE:=.env}
5
6# Path to the file containing allowed paths
7: ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"}
8: ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"}
9
10## Functions
11
12_parse_dotenv_content() {
13 setopt localoptions extendedglob
14
15 local content="$1"
16 local mode="${2:-export}"
17
18 # Validate mode argument
19 case "$mode" in
20 export|test) ;;
21 *)
22 echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2
23 return 1
24 ;;
25 esac
26
27 local node line key value
28 local raw_value expanded prefix remainder var_name escaped_dollar_placeholder
29 local sq dq uq safe
30 local -A parsed_vars
31 local -a nodes lines
32
33 # Parse into command lines separated by `;`, with built-in support for multi-line commands.
34 # (Z:C:) ignores comments and preserves quotes and escapes.
35 #
36 # All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines
37 # by joining all elements between ';'.
38 #
39 # Example input:
40 # VAR1=value1; VAR2=value2
41 # VAR3="multi
42 # line value"
43 # Result:
44 # typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' )
45 # typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' )
46 #
47 nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command
48 for node in "${nodes[@]}"; do
49 if [[ "$node" == ";" ]]; then
50 if [[ -n "$line" ]]; then
51 lines+=("$line")
52 line=""
53 fi
54 continue
55 fi
56
57 [[ -z "$line" ]] || line+=" "
58 line+="$node"
59 done
60
61 local -a forbidden_vars=(
62 NODE_OPTIONS
63 BASH_ENV
64 ENV
65 ZDOTDIR
66 ZSH
67 LD_PRELOAD
68 LD_LIBRARY_PATH
69 DYLD_INSERT_LIBRARIES
70 GIT_CONFIG_GLOBAL
71 GIT_DIR
72 GIT_EDITOR
73 GIT_EXTERNAL_DIFF
74 GIT_EXEC_PATH
75 GIT_PAGER
76 GIT_SSH
77 GIT_SSH_COMMAND
78 GIT_SSL_NO_VERIFY
79 GIT_TEMPLATE_DIR
80 VISUAL
81 PAGER
82 EDITOR
83 ${(k)parameters[(R)*export*special]}
84 )
85 local forbidden="${(j:|:)forbidden_vars}"
86
87
88 # Each line contains a single command line, we need to parse valid KEY=VALUE pairs
89 for line in "${lines[@]}"; do
90 # Strip leading 'export ' keyword
91 line="${line#export[ ]}"
92
93 # Match KEY=VALUE pattern
94 # "A name may be any sequence of alphanumeric characters and underscores"
95 # https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters
96 if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then
97 continue
98 fi
99
100 key="${match[1]}"
101 value="${match[2]}"
102 raw_value="$value"
103
104 # Filter out variables to be ignored for security reasons (best effort)
105 if [[ "$key" == (${~forbidden}) ]]; then
106 continue
107 fi
108
109 # Use tokenization to split value with native shell parsing (handles quotes and escapes)
110 # Ignore any values that parse to multiple words, e.g. `BASE_URL=/ echo command run`
111 local -a words
112 words=("${(@z)value}")
113 if [[ ${#words} -ne 1 ]]; then
114 continue
115 fi
116
117 ## START: FILTER COMMAND EXPANSION
118 #
119 # Filter lines with command expansion not in safe contexts
120 #
121 # READER'S NOTE: this is actually a "best effort" check (works in tests), but
122 # only to prevent setting variables with command substitution. The actual effect
123 # of setting them would not be a vulnerability, because we use `typeset name=value`
124 # and value is a quoted string parsed by zsh itself with `${(Z:C:)content}`.
125 #
126 # What does this mean? If we were to remove this filter block, this is what would happen:
127 #
128 # Input: DANGEROUS=$(echo this is a command)
129 # Output: DANGEROUS='$(echo this is a command)' (literal string, no command execution)
130 #
131 # Check for potential command substitution outside of safe contexts
132 # - single-quoted strings: command substitution is literal there
133 sq="'[^']#'"
134 # - double-quoted strings, but NOT unescaped ` or $(
135 dq='"([^"$`\\]|\\.|\\$[^(\`])#"'
136 # - unquoted text, but NOT unescaped ` or $(
137 uq='([^$`'"'"'"\\]|\\.|\\$[^(\`])#'
138 safe="(${sq}|${dq}|${uq})#"
139 # Remove the longest safe prefix; what remains starts at first unsafe construct
140 remainder="${value##${~safe}}"
141
142 if [[ "$remainder" == *'$('* || "$remainder" == *'`'* ]]; then
143 continue
144 fi
145 ## END: FILTER COMMAND EXPANSION
146
147 # Single-quoted values are fully literal and must not participate in expansion.
148 if [[ "$raw_value" == \'*\' ]]; then
149 value="${(Q)value}"
150 parsed_vars[$key]="$value"
151 if [[ "$mode" == "export" ]]; then
152 typeset -x "$key"="$value"
153 fi
154 continue
155 fi
156
157 # Preserve escaped dollars so they remain literal after unquoting.
158 escaped_dollar_placeholder=$'\001DOTENV_ESCAPED_DOLLAR\001'
159 value="${value//\\\$/$escaped_dollar_placeholder}"
160
161 # Unquote the value to handle special characters and multiline values.
162 value="${(Q)value}"
163
164 # Expand previously parsed in-file variables without partial name matches.
165 expanded=""
166 prefix=""
167 remainder="$value"
168 var_name=""
169 while [[ "$remainder" == *'$'* ]]; do
170 prefix="${remainder%%\$*}"
171 expanded+="$prefix"
172 remainder="${remainder#$prefix}"
173
174 if [[ "$remainder" =~ '^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}(.*)$' ]]; then
175 var_name="${match[1]}"
176 remainder="${match[2]}"
177 elif [[ "$remainder" =~ '^\$([a-zA-Z_][a-zA-Z0-9_]*)(.*)$' ]]; then
178 var_name="${match[1]}"
179 remainder="${match[2]}"
180 else
181 expanded+='$'
182 remainder="${remainder#?}"
183 continue
184 fi
185
186 if [[ -v "parsed_vars[$var_name]" ]]; then
187 expanded+="${parsed_vars[$var_name]}"
188 fi
189 done
190 value="${expanded}${remainder}"
191 value="${value//$escaped_dollar_placeholder/\$}"
192
193 # Store in parsed vars (for in-file expansion)
194 parsed_vars[$key]="$value"
195
196 # Normal mode: export the variable
197 if [[ "$mode" == "export" ]]; then
198 typeset -x "$key"="$value"
199 fi
200 done
201
202 # In test mode, set DOTENV_TEST_VARS
203 typeset -gA DOTENV_TEST_VARS
204 DOTENV_TEST_VARS=("${(@kv)parsed_vars}")
205}
206
207parse_dotenv() {
208 local filename="$1"
209 local mode="${2:-export}"
210 local content
211
212 # Fail if file is too large to avoid DoS
213 zmodload -F zsh/stat b:zstat
214 local -i file_size max_size=10485760 # 10MiB
215 if ! file_size=$(zstat +size "$filename" 2>/dev/null); then
216 echo "dotenv: unable to determine size of file '$filename'" >&2
217 return 1
218 fi
219
220 if (( file_size > max_size )); then
221 echo "dotenv: file '$filename' is too large to parse (size: $file_size bytes)" >&2
222 return 1
223 fi
224
225 content="$(<"$filename")" || return 1
226 _parse_dotenv_content "$content" "$mode"
227}
228
229_dotenv_read_limited() {
230 local filename="$1"
231 local chunk content=""
232 local -i max_size=10485760 total=0 read_size=0 fd read_status
233
234 zmodload zsh/system || return 1
235 exec {fd}<"$filename" || return 1
236
237 while true; do
238 sysread -i $fd -s 65536 -c read_size chunk
239 read_status=$?
240
241 if (( read_status == 5 )); then
242 break
243 elif (( read_status != 0 )); then
244 exec {fd}<&-
245 return 1
246 fi
247
248 (( total += read_size ))
249 if (( total > max_size )); then
250 exec {fd}<&-
251 echo "dotenv: file '$filename' is too large to parse (size: more than $max_size bytes)" >&2
252 return 1
253 fi
254
255 content+="$chunk"
256 done
257
258 exec {fd}<&-
259 REPLY="$content"
260}
261
262_dotenv_check_syntax() {
263 local filename="$1"
264
265 if (( $# == 2 )); then
266 printf '%s' "$2" | zsh -fn /dev/stdin
267 else
268 zsh -fn -- "$filename"
269 fi || {
270 echo "dotenv: error when sourcing '$filename' file" >&2
271 return 1
272 }
273}
274
275source_env() {
276 if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
277 return
278 fi
279
280 if [[ "$ZSH_DOTENV_PROMPT" != false ]]; then
281 local confirmation dirpath="${PWD:A}"
282
283 # make sure there is an (dis-)allowed file
284 touch "$ZSH_DOTENV_ALLOWED_LIST"
285 touch "$ZSH_DOTENV_DISALLOWED_LIST"
286
287 # early return if disallowed
288 if command grep -Fx -q "$dirpath" "$ZSH_DOTENV_DISALLOWED_LIST" &>/dev/null; then
289 return
290 fi
291
292 # check if current directory's .env file is allowed or ask for confirmation
293 if ! command grep -Fx -q "$dirpath" "$ZSH_DOTENV_ALLOWED_LIST" &>/dev/null; then
294 # get cursor column and print new line before prompt if not at line beginning
295 local column
296 echo -ne "\e[6n" > /dev/tty
297 read -t 1 -s -d R column < /dev/tty
298 column="${column##*\[*;}"
299 [[ $column -eq 1 ]] || echo
300
301 # print same-line prompt and output newline character if necessary
302 echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([y]es/[N]o/[a]lways/n[e]ver) "
303 read -k 1 confirmation
304 [[ "$confirmation" = $'\n' ]] || echo
305
306 # check input
307 case "$confirmation" in
308 [yY]) ;;
309 [aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;;
310 [eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;;
311 *) return ;; # interpret anything else as a no
312 esac
313 fi
314 fi
315
316 local content
317 if [[ -p "$ZSH_DOTENV_FILE" ]]; then
318 _dotenv_read_limited "$ZSH_DOTENV_FILE" || return 1
319 content="$REPLY"
320 _dotenv_check_syntax "$ZSH_DOTENV_FILE" "$content" || return 1
321
322 setopt localoptions allexport
323 _parse_dotenv_content "$content"
324 return
325 fi
326
327 _dotenv_check_syntax "$ZSH_DOTENV_FILE" || return 1
328
329 setopt localoptions allexport
330 parse_dotenv "$ZSH_DOTENV_FILE"
331}
332
333autoload -U add-zsh-hook
334add-zsh-hook chpwd source_env
335
336source_env