## Settings

# Filename of the dotenv file to look for
: ${ZSH_DOTENV_FILE:=.env}

# Path to the file containing allowed paths
: ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"}
: ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"}

## 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() {
  if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
    return
  fi

  if [[ "$ZSH_DOTENV_PROMPT" != false ]]; then
    local confirmation dirpath="${PWD:A}"

    # make sure there is an (dis-)allowed file
    touch "$ZSH_DOTENV_ALLOWED_LIST"
    touch "$ZSH_DOTENV_DISALLOWED_LIST"

    # early return if disallowed
    if command grep -Fx -q "$dirpath" "$ZSH_DOTENV_DISALLOWED_LIST" &>/dev/null; then
      return
    fi

    # check if current directory's .env file is allowed or ask for confirmation
    if ! command grep -Fx -q "$dirpath" "$ZSH_DOTENV_ALLOWED_LIST" &>/dev/null; then
      # get cursor column and print new line before prompt if not at line beginning
      local column
      echo -ne "\e[6n" > /dev/tty
      read -t 1 -s -d R column < /dev/tty
      column="${column##*\[*;}"
      [[ $column -eq 1 ]] || echo

      # 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) "
      read -k 1 confirmation
      [[ "$confirmation" = $'\n' ]] || echo

      # check input
      case "$confirmation" in
        [yY]) ;;
        [aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;;
        [eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;;
        *) return ;; # interpret anything else as a no
      esac
    fi
  fi

  local content
  if [[ -p "$ZSH_DOTENV_FILE" ]]; then
    _dotenv_read_limited "$ZSH_DOTENV_FILE" || 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
  parse_dotenv "$ZSH_DOTENV_FILE"
}

autoload -U add-zsh-hook
add-zsh-hook chpwd source_env

source_env
