This commit is contained in:
Thomas Witt 2026-08-05 23:22:14 +03:00 committed by GitHub
commit cc710e1732
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 398 additions and 9 deletions

View file

@ -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.

View file

@ -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 " <empty>"
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

View file

@ -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
}