Parent directory

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