feat(dotenv): improve allow-list glob UX

This commit is contained in:
Thomas Witt 2026-06-29 16:10:43 +03:00
commit 1f840eca9d
No known key found for this signature in database
GPG key ID: 857B3DC11C10C2F2
3 changed files with 363 additions and 31 deletions

View file

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

View file

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

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
}