diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md index a9c9937a2..bcba1f85c 100644 --- a/plugins/dotenv/README.md +++ b/plugins/dotenv/README.md @@ -90,41 +90,58 @@ 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 -Entries in the allowed and disallowed list files are matched as zsh patterns against the -directory path, so wildcards work in addition to exact paths. This is useful when you want -to allow or disallow entire directory trees at once. +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, you can add a single pattern instead of allowing each -one individually: +worktrees live under a common prefix, add a single pattern instead of allowing each one +individually: ```sh -# In your dotenv-allowed.list file: -/Users/me/Dev/my-project-wt-* +dotenv-allow --pattern '/Users/me/Dev/my-project-wt-*' ``` -Note that entries are matched against the whole path as a string (as in -`[[ $dir == pattern ]]`), not with filename globbing: `*` and `?` match any characters -**including `/`**, so `/Users/me/*` also matches nested directories like `/Users/me/a/b`. -The basic zsh pattern operators are supported: `*`, `?`, character classes like `[abc]`, -and alternation like `(foo|bar)`. Operators that require `EXTENDED_GLOB` (such as `#`, -`^` and `~`) are **not** enabled by the plugin. +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-*/*`. -If a literal path contains pattern metacharacters (`*`, `?`, `[`, `(`, etc.), escape them -with a backslash to match the path exactly. Paths added by answering [a]lways or n[e]ver -at the prompt are escaped automatically. Malformed patterns are treated as non-matching. +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. -Lines starting with `#` are treated as comments. Blank lines are ignored, and leading and -trailing whitespace around an entry is stripped. +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 diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh index d4a73b517..a25d1ae33 100644 --- a/plugins/dotenv/dotenv.plugin.zsh +++ b/plugins/dotenv/dotenv.plugin.zsh @@ -274,8 +274,8 @@ _dotenv_check_syntax() { _dotenv_list_match() { emulate -L zsh - local dirpath=$1 list_file=$2 line - local -i matched + local dirpath="${1:A}" list_file=$2 line match + local -a matches [[ -r $list_file ]] || return 1 @@ -285,21 +285,186 @@ _dotenv_list_match() { line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [[ -z "$line" || "$line" == \#* ]] && continue + [[ "$line" == /* || "$line" == ~* ]] || continue + [[ "$line" == /* && "${line:A}" == "$dirpath" ]] && return 0 - # a malformed pattern raises a fatal zsh error; contain it and - # treat the entry as non-matching - matched=1 + matches=() { - [[ $dirpath == ${~line} ]] && matched=0 + matches=(${~line}(N-/)) } always { - (( TRY_BLOCK_ERROR )) && TRY_BLOCK_ERROR=0 + if (( TRY_BLOCK_ERROR )); then + TRY_BLOCK_ERROR=0 + matches=() + fi } - (( matched )) || return 0 + + 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 @@ -309,8 +474,8 @@ 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 _dotenv_list_match "$dirpath" "$ZSH_DOTENV_DISALLOWED_LIST"; then 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 +}