diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md index 8b3f9ecce..bcba1f85c 100644 --- a/plugins/dotenv/README.md +++ b/plugins/dotenv/README.md @@ -90,13 +90,59 @@ ZSH_DOTENV_ALLOWED_LIST=/path/to/dotenv/allowed/list ZSH_DOTENV_DISALLOWED_LIST=/path/to/dotenv/disallowed/list ``` -The file is just a list of directories, separated by a newline character. If you want -to change your decision, just edit the file and remove the line for the directory you want to -change. +The plugin creates these files automatically. You can inspect or update them with: + +- `dotenv-list [allowed|disallowed]`: show the list file location and entries. Without an + argument, it shows both lists. +- `dotenv-edit [allowed|disallowed]`: open a list in `$VISUAL`, `$EDITOR` or `vi`. + Without an argument, it opens the allowed list. +- `dotenv-allow [path]`: add a directory to the allowed list. Without an argument, it + adds the current directory. +- `dotenv-disallow [path]`: add a directory to the disallowed list. Without an argument, + it adds the current directory. +- `dotenv-allow --pattern 'pattern'` and `dotenv-disallow --pattern 'pattern'`: add a + zsh filename pattern instead of a literal path. + +Paths added by `dotenv-allow`, `dotenv-disallow`, [a]lways or n[e]ver are escaped so glob +metacharacters in real directory names are treated literally. Use `--pattern` only when you +intentionally want wildcard matching. + +If you edit the files directly, use one absolute directory path or pattern per line. +Lines starting with `#` are treated as comments. Blank lines are ignored, and leading and +trailing whitespace around an entry is stripped. NOTE: if a directory is found in both the allowed and disallowed lists, the disallowed list takes preference, _i.e._ the .env file will never be sourced. +### Glob/Wildcard Patterns + +Exact absolute directory paths still match literally. Other entries in the allowed and +disallowed list files are expanded with zsh filename globbing and matched against the +current directory's absolute path. This keeps wildcard matching limited to actual directory +paths instead of arbitrary string prefixes. + +For example, if you use [git worktrees](https://git-scm.com/docs/git-worktree) and all your +worktrees live under a common prefix, add a single pattern instead of allowing each one +individually: + +```sh +dotenv-allow --pattern '/Users/me/Dev/my-project-wt-*' +``` + +With filename globbing, `*` matches one path component: `/Users/me/Dev/my-project-wt-*` +matches `/Users/me/Dev/my-project-wt-auth`, but not +`/Users/me/Dev/my-project-wt-auth/api`. To match nested directories, include the nested +path component in the pattern, for example `/Users/me/Dev/my-project-wt-*/*`. + +Patterns must be absolute paths or start with `~`. The basic zsh filename pattern operators +are supported, such as `*`, `?`, character classes like `[abc]`, and alternation like +`(foo|bar)`. Operators that require `EXTENDED_GLOB` (such as `#`, `^` and pattern +exclusion `~`) are not enabled by the plugin. + +If a manually edited literal path contains pattern metacharacters (`*`, `?`, `[`, `(`, +etc.), escape them with a backslash to match the path exactly. Malformed patterns are +treated as non-matching. + ## Named Pipe (FIFO) Support The plugin supports `.env` files provided as UNIX named pipes (FIFOs) in addition to regular files. diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh index 72839a501..a25d1ae33 100644 --- a/plugins/dotenv/dotenv.plugin.zsh +++ b/plugins/dotenv/dotenv.plugin.zsh @@ -272,6 +272,199 @@ _dotenv_check_syntax() { } } +_dotenv_list_match() { + emulate -L zsh + local dirpath="${1:A}" list_file=$2 line match + local -a matches + + [[ -r $list_file ]] || return 1 + + while IFS= read -r line || [[ -n $line ]]; do + # tolerate CRLF line endings and surrounding whitespace + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" || "$line" == \#* ]] && continue + [[ "$line" == /* || "$line" == ~* ]] || continue + [[ "$line" == /* && "${line:A}" == "$dirpath" ]] && return 0 + + matches=() + { + matches=(${~line}(N-/)) + } always { + if (( TRY_BLOCK_ERROR )); then + TRY_BLOCK_ERROR=0 + matches=() + fi + } + + for match in "${matches[@]}"; do + [[ "${match:A}" == "$dirpath" ]] && return 0 + done + done < "$list_file" 2>/dev/null + + return 1 +} + +_dotenv_ensure_list_file() { + emulate -L zsh + local list_file=$1 + + [[ -n "$list_file" ]] || return 1 + command mkdir -p -- "${list_file:h}" || return 1 + [[ -e "$list_file" ]] || command touch -- "$list_file" || return 1 +} + +_dotenv_list_file_for() { + emulate -L zsh + + case "$1" in + allowed|allow) + REPLY="$ZSH_DOTENV_ALLOWED_LIST" + ;; + disallowed|disallow|denied|deny) + REPLY="$ZSH_DOTENV_DISALLOWED_LIST" + ;; + *) + echo "dotenv: expected 'allowed' or 'disallowed'" >&2 + return 1 + ;; + esac +} + +_dotenv_append_list_entry() { + emulate -L zsh + local list_file=$1 entry=$2 + + _dotenv_ensure_list_file "$list_file" || return 1 + + if command grep -Fx -q -- "$entry" "$list_file" 2>/dev/null; then + REPLY=present + return + fi + + print -r -- "$entry" >> "$list_file" || return 1 + REPLY=added +} + +_dotenv_add_list_entry_usage() { + print -r -- "Usage: $1 [--pattern] [path-or-pattern]" +} + +_dotenv_add_list_entry_command() { + emulate -L zsh + local list_file=$1 command_name=$2 entry list_entry resolved_path + local as_pattern=false + shift 2 + + if [[ "$1" == -h || "$1" == --help ]]; then + _dotenv_add_list_entry_usage "$command_name" + return + fi + + if [[ "$1" == --pattern ]]; then + as_pattern=true + shift + fi + + if (( $# > 1 )) || [[ "$as_pattern" == true && $# -ne 1 ]]; then + _dotenv_add_list_entry_usage "$command_name" >&2 + return 1 + fi + + entry="${1:-$PWD}" + if [[ "$as_pattern" == true ]]; then + if [[ "$entry" != /* && "$entry" != ~* ]]; then + echo "dotenv: patterns must be absolute paths or start with '~'" >&2 + return 1 + fi + + list_entry="$entry" + else + resolved_path="${entry:A}" + list_entry="${(b)resolved_path}" + fi + + _dotenv_append_list_entry "$list_file" "$list_entry" || return 1 + + case "$REPLY" in + added) echo "dotenv: added '$list_entry' to $list_file" ;; + present) echo "dotenv: '$list_entry' is already in $list_file" ;; + esac +} + +dotenv-allow() { + _dotenv_add_list_entry_command "$ZSH_DOTENV_ALLOWED_LIST" dotenv-allow "$@" +} + +dotenv-disallow() { + _dotenv_add_list_entry_command "$ZSH_DOTENV_DISALLOWED_LIST" dotenv-disallow "$@" +} + +_dotenv_print_list() { + emulate -L zsh + local list_name=$1 list_file line + + _dotenv_list_file_for "$list_name" || return 1 + list_file="$REPLY" + _dotenv_ensure_list_file "$list_file" || return 1 + + echo "$list_name: $list_file" + if [[ ! -s "$list_file" ]]; then + echo " " + return + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + print -r -- " $line" + done < "$list_file" +} + +dotenv-list() { + emulate -L zsh + + if [[ "$1" == -h || "$1" == --help ]]; then + echo "Usage: dotenv-list [allowed|disallowed]" + return + fi + + case $# in + 0) + _dotenv_print_list allowed + _dotenv_print_list disallowed + ;; + 1) + _dotenv_print_list "$1" + ;; + *) + echo "Usage: dotenv-list [allowed|disallowed]" >&2 + return 1 + ;; + esac +} + +dotenv-edit() { + emulate -L zsh + local list_name="${1:-allowed}" list_file editor + + if [[ "$1" == -h || "$1" == --help ]]; then + echo "Usage: dotenv-edit [allowed|disallowed]" + return + fi + + if (( $# > 1 )); then + echo "Usage: dotenv-edit [allowed|disallowed]" >&2 + return 1 + fi + + _dotenv_list_file_for "$list_name" || return 1 + list_file="$REPLY" + _dotenv_ensure_list_file "$list_file" || return 1 + + editor="${VISUAL:-${EDITOR:-vi}}" + ${=editor} "$list_file" +} + source_env() { if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then return @@ -281,16 +474,16 @@ source_env() { local confirmation dirpath="${PWD:A}" # make sure there is an (dis-)allowed file - touch "$ZSH_DOTENV_ALLOWED_LIST" - touch "$ZSH_DOTENV_DISALLOWED_LIST" + _dotenv_ensure_list_file "$ZSH_DOTENV_ALLOWED_LIST" || return 1 + _dotenv_ensure_list_file "$ZSH_DOTENV_DISALLOWED_LIST" || return 1 # early return if disallowed - if command grep -Fx -q "$dirpath" "$ZSH_DOTENV_DISALLOWED_LIST" &>/dev/null; then + if _dotenv_list_match "$dirpath" "$ZSH_DOTENV_DISALLOWED_LIST"; 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 + if ! _dotenv_list_match "$dirpath" "$ZSH_DOTENV_ALLOWED_LIST"; then # get cursor column and print new line before prompt if not at line beginning local column echo -ne "\e[6n" > /dev/tty @@ -306,8 +499,8 @@ source_env() { # check input case "$confirmation" in [yY]) ;; - [aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;; - [eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;; + [aA]) print -r -- "${(b)dirpath}" >> "$ZSH_DOTENV_ALLOWED_LIST" ;; + [eE]) print -r -- "${(b)dirpath}" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;; *) return ;; # interpret anything else as a no esac fi diff --git a/plugins/dotenv/tests/allow-list.zunit b/plugins/dotenv/tests/allow-list.zunit new file mode 100644 index 000000000..ad9d93f64 --- /dev/null +++ b/plugins/dotenv/tests/allow-list.zunit @@ -0,0 +1,150 @@ +#!/usr/bin/env zunit + +@setup { + typeset -g tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv-list.XXXXXX")" + typeset -g allowed_list="$tmpdir/allowed.list" + typeset -g disallowed_list="$tmpdir/disallowed.list" + ZSH_DOTENV_ALLOWED_LIST="$allowed_list" + ZSH_DOTENV_DISALLOWED_LIST="$disallowed_list" +} + +@teardown { + [[ -n "$tmpdir" && -d "$tmpdir" ]] && command rm -rf "$tmpdir" + unset DOTENV_PRECEDENCE 2>/dev/null +} + +@test 'list match supports escaped literal paths' { + local dir="$tmpdir/project[one]" + command mkdir -p "$dir" + print -r -- "${(b)dir}" > "$allowed_list" + + run _dotenv_list_match "$dir" "$allowed_list" + assert $state equals 0 +} + +@test 'list match keeps exact absolute entries literal' { + local dir="$tmpdir/project[one]" + command mkdir -p "$dir" + print -r -- "$dir" > "$allowed_list" + + run _dotenv_list_match "$dir" "$allowed_list" + assert $state equals 0 +} + +@test 'list match ignores comments blank lines and CRLF endings' { + local dir="$tmpdir/project" + command mkdir -p "$dir" + { + print -r -- ' # comment' + print -r -- ' ' + printf '%s\r\n' " ${(b)dir} " + } > "$allowed_list" + + run _dotenv_list_match "$dir" "$allowed_list" + assert $state equals 0 +} + +@test 'filename glob matches direct directories only' { + local project="$tmpdir/worktrees/app-one" + local nested="$project/nested" + command mkdir -p "$nested" + print -r -- "$tmpdir/worktrees/*" > "$allowed_list" + + run _dotenv_list_match "$project" "$allowed_list" + assert $state equals 0 + + run _dotenv_list_match "$nested" "$allowed_list" + assert $state equals 1 +} + +@test 'filename glob can match nested directories explicitly' { + local nested="$tmpdir/worktrees/app-one/nested" + command mkdir -p "$nested" + print -r -- "$tmpdir/worktrees/*/nested" > "$allowed_list" + + run _dotenv_list_match "$nested" "$allowed_list" + assert $state equals 0 +} + +@test 'malformed patterns are ignored without breaking later entries' { + local dir="$tmpdir/project" + command mkdir -p "$dir" + print -r -- "$tmpdir/[" > "$allowed_list" + print -r -- "${(b)dir}" >> "$allowed_list" + + run _dotenv_list_match "$dir" "$allowed_list" + assert $state equals 0 +} + +@test 'relative entries are ignored' { + print -r -- '.' > "$allowed_list" + + run _dotenv_list_match "$PWD" "$allowed_list" + assert $state equals 1 +} + +@test 'disallowed entries take precedence over allowed entries' { + local project="$tmpdir/project" + command mkdir -p "$project" + print -r -- 'DOTENV_PRECEDENCE=loaded' > "$project/.env" + print -r -- "${(b)project}" > "$allowed_list" + print -r -- "${(b)project}" > "$disallowed_list" + + ( + ZSH_DOTENV_FILE=.env + ZSH_DOTENV_PROMPT=true + cd "$project" + source_env + [[ ! -v DOTENV_PRECEDENCE ]] + ) + assert $? equals 0 +} + +@test 'dotenv-allow writes escaped literal absolute paths' { + local dir="$tmpdir/project[one]" + local resolved_dir + command mkdir -p "$dir" + resolved_dir="${dir:A}" + + dotenv-allow "$dir" >/dev/null + + assert "$(<"$allowed_list")" equals "${(b)resolved_dir}" +} + +@test 'dotenv-disallow with pattern writes the raw pattern' { + local pattern="$tmpdir/worktrees/*" + + dotenv-disallow --pattern "$pattern" >/dev/null + + assert "$(<"$disallowed_list")" equals "$pattern" +} + +@test 'dotenv-allow does not append duplicate entries' { + local dir="$tmpdir/project" + local -a lines + command mkdir -p "$dir" + + dotenv-allow "$dir" >/dev/null + dotenv-allow "$dir" >/dev/null + lines=("${(@f)"$(<"$allowed_list")"}") + + assert ${#lines} equals 1 +} + +@test 'dotenv-list shows list location and entries' { + local output + print -r -- "$tmpdir/project" > "$allowed_list" + + output="$(dotenv-list allowed)" + + [[ "$output" == *"$allowed_list"* && "$output" == *"$tmpdir/project"* ]] + assert $? equals 0 +} + +@test 'dotenv-edit opens the selected list with the configured editor' { + VISUAL= EDITOR=true dotenv-edit disallowed + assert $? equals 0 + + [[ -f "$disallowed_list" ]] + assert $? equals 0 +}