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 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 The plugin creates these files automatically. You can inspect or update them with:
to change your decision, just edit the file and remove the line for the directory you want to
change. - `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 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. takes preference, _i.e._ the .env file will never be sourced.
### Glob/Wildcard Patterns ### Glob/Wildcard Patterns
Entries in the allowed and disallowed list files are matched as zsh patterns against the Exact absolute directory paths still match literally. Other entries in the allowed and
directory path, so wildcards work in addition to exact paths. This is useful when you want disallowed list files are expanded with zsh filename globbing and matched against the
to allow or disallow entire directory trees at once. 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 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 worktrees live under a common prefix, add a single pattern instead of allowing each one
one individually: individually:
```sh ```sh
# In your dotenv-allowed.list file: dotenv-allow --pattern '/Users/me/Dev/my-project-wt-*'
/Users/me/Dev/my-project-wt-*
``` ```
Note that entries are matched against the whole path as a string (as in With filename globbing, `*` matches one path component: `/Users/me/Dev/my-project-wt-*`
`[[ $dir == pattern ]]`), not with filename globbing: `*` and `?` match any characters matches `/Users/me/Dev/my-project-wt-auth`, but not
**including `/`**, so `/Users/me/*` also matches nested directories like `/Users/me/a/b`. `/Users/me/Dev/my-project-wt-auth/api`. To match nested directories, include the nested
The basic zsh pattern operators are supported: `*`, `?`, character classes like `[abc]`, path component in the pattern, for example `/Users/me/Dev/my-project-wt-*/*`.
and alternation like `(foo|bar)`. Operators that require `EXTENDED_GLOB` (such as `#`,
`^` and `~`) are **not** enabled by the plugin.
If a literal path contains pattern metacharacters (`*`, `?`, `[`, `(`, etc.), escape them Patterns must be absolute paths or start with `~`. The basic zsh filename pattern operators
with a backslash to match the path exactly. Paths added by answering [a]lways or n[e]ver are supported, such as `*`, `?`, character classes like `[abc]`, and alternation like
at the prompt are escaped automatically. Malformed patterns are treated as non-matching. `(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 If a manually edited literal path contains pattern metacharacters (`*`, `?`, `[`, `(`,
trailing whitespace around an entry is stripped. etc.), escape them with a backslash to match the path exactly. Malformed patterns are
treated as non-matching.
## Named Pipe (FIFO) Support ## Named Pipe (FIFO) Support

View file

@ -274,8 +274,8 @@ _dotenv_check_syntax() {
_dotenv_list_match() { _dotenv_list_match() {
emulate -L zsh emulate -L zsh
local dirpath=$1 list_file=$2 line local dirpath="${1:A}" list_file=$2 line match
local -i matched local -a matches
[[ -r $list_file ]] || return 1 [[ -r $list_file ]] || return 1
@ -285,21 +285,186 @@ _dotenv_list_match() {
line="${line#"${line%%[![:space:]]*}"}" line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}" line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" == \#* ]] && continue [[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" == /* || "$line" == ~* ]] || continue
[[ "$line" == /* && "${line:A}" == "$dirpath" ]] && return 0
# a malformed pattern raises a fatal zsh error; contain it and matches=()
# treat the entry as non-matching
matched=1
{ {
[[ $dirpath == ${~line} ]] && matched=0 matches=(${~line}(N-/))
} always { } 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 done < "$list_file" 2>/dev/null
return 1 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() { source_env() {
if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then
return return
@ -309,8 +474,8 @@ source_env() {
local confirmation dirpath="${PWD:A}" local confirmation dirpath="${PWD:A}"
# make sure there is an (dis-)allowed file # make sure there is an (dis-)allowed file
touch "$ZSH_DOTENV_ALLOWED_LIST" _dotenv_ensure_list_file "$ZSH_DOTENV_ALLOWED_LIST" || return 1
touch "$ZSH_DOTENV_DISALLOWED_LIST" _dotenv_ensure_list_file "$ZSH_DOTENV_DISALLOWED_LIST" || return 1
# early return if disallowed # early return if disallowed
if _dotenv_list_match "$dirpath" "$ZSH_DOTENV_DISALLOWED_LIST"; then 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
}