From 7bffe74c453ee8344465648782eb625622578ee7 Mon Sep 17 00:00:00 2001 From: Sal Date: Fri, 28 Aug 2026 03:21:58 +0100 Subject: [PATCH 1/2] fix(loader): repair clean-machine install and harden init.zsh The loader downloaded its git progress filter from lib/zsh/git-process-output.zsh under a public/zsh/ path that does not exist in z-shell/zi. The fetch returned 404, _zi_setup returned 1 with no diagnostic, and every clean-machine `source init.zsh && zzinit` aborted before cloning. Verified against the published endpoint before the fix. Correctness - Point the progress-filter URL at the published lib/zsh path and treat the filter as cosmetic: a failed fetch now falls back to plain output instead of aborting the install. - Stop wrapping the zi.zsh source in `emulate -L zsh`. zi.zsh's top level intentionally sets AUTO_CD and marks path, manpath, cdpath, mailpath, fpath, and logpath exported and unique; `-L` localized those to the loader's own function and discarded them. Guard only the options that would corrupt zi.zsh's parsing, then restore the caller's values. - Read `${pipestatus[1]}` for the clone status. The previous code inspected the pipeline's exit status, which reported the progress filter rather than git. Defaults ownership - Keep only the settings that must exist before Zi does: REPOSITORY, STREAM, HOME_DIR, BIN_DIR, CACHE_DIR, CONFIG_DIR, MUTE_WARNINGS. CACHE_DIR and CONFIG_DIR stay because zi.zsh's own fallbacks are not XDG-first: they prefer $HOME/.cache and $HOME/.config when those exist and only then consult the XDG variables. - Drop the ZPFX, ZMODULES_DIR, and ZCOMPDUMP_PATH duplicates. zi.zsh derives them identically, and resolved paths are unchanged. - Document the deliberate HOME_DIR divergence between the two entry points instead of leaving it implicit. Security and diagnostics - Download the executed progress filter into a `mktemp -d` directory. The previous fixed ${TMPDIR}/zi path let another user pre-place a file that the loader would chmod +x and run, and the [[ ! -f ]] guard skipped the download entirely when one was already there. - Validate ZI[STREAM] with `git check-ref-format` before it reaches `git clone --branch`, rejecting option-like values. - Scope `chmod -R go-w` to BIN_DIR rather than all of HOME_DIR, which also holds plugins, snippets, and other user data. - Report a diagnostic on every failure path, including the missing curl/wget and missing git cases that previously failed silently. - Keep zzinit and its helpers defined when a run fails so the user can fix the cause and retry; only unset them on success. - Sequence completion registration and zpmod independently so one failure no longer skips the other. - Add `typeset -gU module_path` before appending, and surface a zmodload failure unless MUTE_WARNINGS is set. - Gate the history defaults behind ZI[LOADER_HISTORY]; loading a plugin manager should not create directories and touch files by default. Clone cost - Use `--filter=blob:none --single-branch` in both the loader and the installer's minimal .zshrc profile. The installer previously used `--depth=1`, which degrades ZI[VERSION] from a `git describe` tag to a bare short SHA. Verified: v1.1.0-83-g1e41c12 preserved, clone 1.8M. CI - The "init.zsh sync drift" step passed --local and --remote as the same checked-out file, so it could never detect drift. Replace it with a scheduled loader-drift workflow that compares repository source against both the published init.zshell.dev endpoint and raw main. Tests - Cover caller-option preservation, strict XDG paths, the history opt-out, ZI[STREAM] rejection, the progress-filter URL, and the private temporary directory. Suite passes 16/16; zsh-lint clean under the configured profile. --- .github/workflows/check-linux.yml | 2 - .github/workflows/loader-drift.yml | 47 +++++ docs/README.md | 48 +++++ public/checksum.txt | 4 +- public/sh/install.sh | 2 +- public/zsh/init.zsh | 298 ++++++++++++++++++++++------- tests/installers.sh | 145 ++++++++++++++ 7 files changed, 474 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/loader-drift.yml diff --git a/.github/workflows/check-linux.yml b/.github/workflows/check-linux.yml index 7c027d3..27389ba 100644 --- a/.github/workflows/check-linux.yml +++ b/.github/workflows/check-linux.yml @@ -64,5 +64,3 @@ jobs: zmodload zi/zpmod zpmod source-study -l shell: zsh {0} - - name: "⚙️ Check: init.zsh sync drift" - run: sh public/sh/sync-init.sh --local ./public/zsh/init.zsh --remote ./public/zsh/init.zsh --checksum-url ./public/checksum.txt diff --git a/.github/workflows/loader-drift.yml b/.github/workflows/loader-drift.yml new file mode 100644 index 0000000..7cca30e --- /dev/null +++ b/.github/workflows/loader-drift.yml @@ -0,0 +1,47 @@ +--- +name: Loader Drift +# Verify that the published loader endpoint serves the same bytes as the +# loader source on main. +# +# The former "init.zsh sync drift" step in check-linux.yml passed +# --local and --remote as the same checked-out file, so it could never +# detect drift. The meaningful comparison is repository source against the +# published artifact, which only exists after a deployment. +on: + workflow_run: + workflows: ["GitHub Pages"] + types: [completed] + schedule: + # Daily, offset off the hour to avoid the scheduler peak. + - cron: "17 6 * * *" + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + published-loader: + name: Published loader matches source + if: github.repository == 'z-shell/src' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: ⤵️ Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + - name: "⚙️ Check: published loader endpoint" + run: | + sh ./public/sh/sync-init.sh \ + --local ./public/zsh/init.zsh \ + --remote https://init.zshell.dev \ + --checksum-url ./public/checksum.txt + - name: "⚙️ Check: raw main loader endpoint" + run: | + sh ./public/sh/sync-init.sh \ + --local ./public/zsh/init.zsh \ + --checksum-url ./public/checksum.txt diff --git a/docs/README.md b/docs/README.md index a7c209e..fa2f01d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,54 @@ - **Installer**: [get.zshell.dev](https://get.zshell.dev) - **jsDeliver CDN**: [cdn.jsdelivr.net/gh/z-shell/src@main/](https://cdn.jsdelivr.net/gh/z-shell/src@main/) +### Loader configuration + +`public/zsh/init.zsh` defines `zzinit()`. Sourcing the file only declares the +function and applies defaults; nothing is cloned, sourced, or written until +`zzinit` is called. + +The loader owns the settings that must exist before Zi does: + +| Setting | Default | Purpose | +| ------------------ | ------------------------------------------- | -------------------------------- | +| `ZI[REPOSITORY]` | `https://github.com/z-shell/zi.git` | Clone source | +| `ZI[STREAM]` | `main` | Branch or tag to clone | +| `ZI[HOME_DIR]` | `${XDG_DATA_HOME:-$HOME/.local/share}/zi` | Working-directory root | +| `ZI[BIN_DIR]` | `${ZI[HOME_DIR]}/bin` | Where `zi.zsh` is cloned | +| `ZI[CACHE_DIR]` | `${XDG_CACHE_HOME:-$HOME/.cache}/zi` | Cache root, strict XDG | +| `ZI[CONFIG_DIR]` | `${XDG_CONFIG_HOME:-$HOME/.config}/zi` | Config root, strict XDG | +| `ZI[MUTE_WARNINGS]`| `0` | Read directly by user config | + +`ZI[CACHE_DIR]` and `ZI[CONFIG_DIR]` are deliberately kept here rather than +left to `zi.zsh`. Zi does not currently resolve these two XDG-first: it prefers +`$HOME/.cache` and `$HOME/.config` whenever those directories exist and only +consults `XDG_CACHE_HOME`/`XDG_CONFIG_HOME` otherwise. Removing them today +would relocate the cache of every user who sets the XDG variables while still +having the legacy directories present. + +Zi is adopting XDG-first resolution. Once that is released these two +assignments become redundant rather than wrong, since both sides resolve to the +same paths. Remove them only after the released `zi.zsh` resolves both +XDG-first, and keep `test_init_xdg_paths_are_strict` either way. + +Every other `ZI[...]` key is owned by `zi.zsh` and derived from those values +with identical definitions. Set one in `.zshrc` before sourcing the loader to +override it; do not add a duplicate default to the loader. See the +[customization guide](https://wiki.zshell.dev/docs/guides/customization#customizing-paths). + +`ZI[HOME_DIR]` is the one deliberate divergence: the loader defaults to the XDG +path, while `zi.zsh`'s current fallback for an unset value is `${HOME}/.zi`. +The loader always assigns the value before `zi.zsh` runs, so they never +disagree within a session, but a direct `source zi.zsh` without the loader uses +the other layout. Zi's in-progress XDG-first work is expected to close this +gap; the loader already uses the intended destination. + +One loader-only toggle exists: + +| Setting | Default | Purpose | +| --------------------- | ------- | ----------------------------------------------------------- | +| `ZI[LOADER_HISTORY]` | `1` | Set to `0` to leave `HISTFILE`/`SAVEHIST`/`HISTSIZE` alone | + ### Maintainer — Verify and Sync Loader Check whether the local `public/zsh/init.zsh` matches the canonical GitHub raw `main` copy: diff --git a/public/checksum.txt b/public/checksum.txt index 2957c73..fc7b1d2 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,4 +1,4 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh -6ee1ced1b73ab9d6f8a17e26505dd5de588b2a0af2832603ae04eb2866763ed9 public/sh/install.sh +3fe6363f38e4b7f937300d001b4a3917d8357688facc461f0dc23e1f82d71231 public/sh/install.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh -424d66dca9bc26dd225afa45ccf15e42c8cf1f9e5e68fa51b469969bb5d51901 public/zsh/init.zsh +87ef5849be6f3860a464390b660371a59ae2ff8f9c399cd703fc4aeaa6122478 public/zsh/init.zsh diff --git a/public/sh/install.sh b/public/sh/install.sh index bd911bc..626f530 100755 --- a/public/sh/install.sh +++ b/public/sh/install.sh @@ -207,7 +207,7 @@ MAIN_PROFILE() { if [[ ! -f ${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh ]]; then print -P "%F{33}▓▒░ %F{160}Installing (%F{33}z-shell/zi%F{160})…%f" command mkdir -p "${ZI_HOME}" && command chmod go-rwX "${ZI_HOME}" - command git clone -q --depth=1 --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME}/${ZI_BIN_DIR_NAME}" && \\ + command git clone -q --filter=blob:none --single-branch --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME}/${ZI_BIN_DIR_NAME}" && \\ print -P "%F{33}▓▒░ %F{34}Installation successful.%f%b" || \\ print -P "%F{160}▓▒░ The clone has failed.%f%b" fi diff --git a/public/zsh/init.zsh b/public/zsh/init.zsh index c72db20..4f81a2d 100755 --- a/public/zsh/init.zsh +++ b/public/zsh/init.zsh @@ -4,35 +4,104 @@ # # Zi Loader — bootstrap and source the Zi plugin manager. # -# Sourced early in .zshrc. Defines zzinit() which clones Zi on first run, -# sources zi.zsh, registers completions, and loads zpmod if built. -# All helper functions are cleaned up after zzinit() returns. +# Execution profile: startup-file. This is sourced early in .zshrc and +# deliberately makes phase-owned global effects; it is not a caller-preserving +# sourced library. +# +# Sourcing this file only defines zzinit(). Nothing is cloned, sourced, or +# written until zzinit() is called: +# +# if [[ -r "${XDG_CONFIG_HOME:-${HOME}/.config}/zi/init.zsh" ]]; then +# source "${XDG_CONFIG_HOME:-${HOME}/.config}/zi/init.zsh" && zzinit +# fi +# +# Documented global effects of zzinit(): +# - sources zi.zsh, which owns its own documented global effects +# - registers the zi completion in _comps when compinit has already run +# - appends to module_path and loads zi/zpmod when the module is built +# - on first run, clones ZI[REPOSITORY] into ZI[BIN_DIR] +# - unsets its own helper functions on success +# +# All helper functions and zzinit() itself are removed after a successful run. +# On failure they are kept so the user can read the diagnostics and retry. # ── Zi Configuration ────────────────────────────────────────────────────────── typeset -ghA ZI -# https://wiki.zshell.dev/docs/guides/customization +# Settings this loader must know before Zi exists, because they decide what to +# clone and where. See https://wiki.zshell.dev/docs/guides/customization : "${ZI[REPOSITORY]:=https://github.com/z-shell/zi.git}" : "${ZI[STREAM]:=main}" : "${ZI[HOME_DIR]:=${XDG_DATA_HOME:-$HOME/.local/share}/zi}" : "${ZI[BIN_DIR]:=${ZI[HOME_DIR]}/bin}" + +# Strict XDG placement. These are kept because zi.zsh does not currently +# resolve these two XDG-first: it prefers $HOME/.cache and $HOME/.config +# whenever those exist and only consults XDG_CACHE_HOME / XDG_CONFIG_HOME +# otherwise. Dropping them today would silently relocate the cache of every +# user who sets the XDG variables while still having the legacy directories +# present. +# +# Zi is adopting XDG-first resolution. Once released, these two assignments +# become redundant rather than wrong: they resolve to the same paths, so the +# loader keeps working unchanged. Remove them only after the released zi.zsh +# resolves both XDG-first, and keep the regression test that pins the expected +# paths either way. : "${ZI[CACHE_DIR]:=${XDG_CACHE_HOME:-$HOME/.cache}/zi}" : "${ZI[CONFIG_DIR]:=${XDG_CONFIG_HOME:-$HOME/.config}/zi}" -# https://wiki.zshell.dev/community/zsh_plugin_standard#global-parameter-with-prefix -: "${ZPFX:=${ZI[HOME_DIR]}/polaris}" -: "${ZI[ZMODULES_DIR]:=${ZI[HOME_DIR]}/zmodules}" -: "${ZI[ZCOMPDUMP_PATH]:=${ZI[CACHE_DIR]}/.zcompdump}" +# Retained for compatibility: user configuration and third-party plugins read +# this value directly, so it must be defined rather than merely defaulted +# inside zi.zsh. : "${ZI[MUTE_WARNINGS]:=0}" -# History defaults -: "${HISTFILE:=${XDG_STATE_HOME:-$HOME/.local/state}/zsh/history}" -[[ -e "$HISTFILE" ]] || { command mkdir -p "${HISTFILE:h}"; command touch "$HISTFILE"; } -[[ -w "$HISTFILE" ]] && typeset -gx SAVEHIST=440000 HISTSIZE=441000 +# NOTE ON DEFAULTS +# The settings above are loader policy: they decide what to clone, where it +# lands, and which base directories are used. Zi's current fallback for an +# unset ZI[HOME_DIR] is ${HOME}/.zi, so a direct `source zi.zsh` without this +# loader produces a different layout. The loader always assigns these before +# zi.zsh runs, so the two never disagree within one session. Zi's in-progress +# XDG-first work is expected to close this gap; the loader's XDG values are +# already the intended destination, so no change is needed here when it lands. +# +# Every other ZI[...] key is owned by zi.zsh and derived from the values above, +# with identical definitions. Do not duplicate them here; set them in .zshrc +# before this file is sourced if you want to override one. The full set zi.zsh +# honours: +# +# Paths COMPLETIONS_DIR PLUGINS_DIR SNIPPETS_DIR SERVICES_DIR +# THEMES_DIR ZMODULES_DIR MAN_DIR LOG_DIR MAIL_DIR CDPATH_DIR +# ZCOMPDUMP_PATH ZPFX +# Behaviour OPTIMIZE_OUT_DISK_ACCESSES COMPINIT_OPTS INTERNAL_ALIASES +# PKG_OWNER +# +# Reference: https://wiki.zshell.dev/docs/guides/customization#customizing-paths + +# Loader behaviour toggles. +# +# ZI[LOADER_HISTORY] 1 (default) applies the history defaults below. Set to 0 +# before sourcing to leave HISTFILE, SAVEHIST, and HISTSIZE +# entirely to your own configuration. +: "${ZI[LOADER_HISTORY]:=1}" + +# History defaults. These are a convenience for the installer's minimal .zshrc +# and are unrelated to loading Zi; ZI[LOADER_HISTORY]=0 disables them. +if [[ ${ZI[LOADER_HISTORY]} == 1 ]]; then + : "${HISTFILE:=${XDG_STATE_HOME:-$HOME/.local/state}/zsh/history}" + [[ -e "$HISTFILE" ]] || { command mkdir -p "${HISTFILE:h}" && command touch "$HISTFILE"; } + [[ -w "$HISTFILE" ]] && typeset -gx SAVEHIST=440000 HISTSIZE=441000 +fi # ── Bootstrap Helpers ───────────────────────────────────────────────────────── +# Report a loader failure on stderr. +_zi_err() { + builtin emulate -L zsh + builtin print -u2 -P "%F{160}▓▒░ Zi loader: %f%b$1" + return 0 +} + # Fetch content from a URL to stdout. _zi_fetch() { builtin emulate -L zsh @@ -41,96 +110,191 @@ _zi_fetch() { elif (( $+commands[wget] )); then command wget -qO- "$1" else + _zi_err "neither curl nor wget is available; cannot download." return 255 fi } -# Clone Zi repository if it doesn't exist. +# Reject a stream name that git would not accept as a branch, before it reaches +# `git clone --branch` and produces an unattributed git error. +_zi_check_stream() { + builtin emulate -L zsh + local stream="${ZI[STREAM]}" + if [[ -z $stream ]]; then + _zi_err "ZI[STREAM] is empty; set it to a branch or tag name." + return 1 + fi + if [[ $stream == -* || $stream == *[[:space:]]* ]]; then + _zi_err "ZI[STREAM] is not a valid ref name: ${(qqq)stream}" + return 1 + fi + if (( $+commands[git] )) && + ! command git check-ref-format --allow-onelevel "$stream" 2>/dev/null; then + _zi_err "ZI[STREAM] is not a valid ref name: ${(qqq)stream}" + return 1 + fi + return 0 +} + +# Clone the Zi repository if it is not already present. _zi_setup() { builtin emulate -L zsh builtin autoload colors; colors local -a git_refs local tmp_dir show_process process_url + integer clone_status=0 - if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - tmp_dir="${TMPDIR:-/tmp}/zi" - [[ -d "$tmp_dir" ]] || command mkdir -p "$tmp_dir" - - show_process="${tmp_dir}/git-process.zsh" - process_url="https://raw.githubusercontent.com/z-shell/zi/main/public/zsh/git-process-output.zsh" - - if [[ ! -f "$show_process" ]]; then - if _zi_fetch "$process_url" > "${tmp_dir}/git-process.zsh"; then - command chmod a+x "${tmp_dir}/git-process.zsh" - else - return 1 - fi - fi + [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]] && return 0 - (( $+commands[clear] )) && command clear - builtin print -P "%F{33}▓▒░ %F{160}Installing interactive & feature-rich plugin manager (%F{33}z-shell/zi%F{160})%f%b…\n" - command mkdir -p "${ZI[BIN_DIR]}" && \ - command git clone --verbose --progress --branch \ - "${ZI[STREAM]}" "${ZI[REPOSITORY]}" "${ZI[BIN_DIR]}" \ - |& { command "$show_process" || command cat; } - - if [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - command chmod -R go-w "${ZI[HOME_DIR]}" - git_refs=("${(f@)$(builtin cd -q "${ZI[BIN_DIR]}" && command git log --color --graph --abbrev-commit \ - --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' | command head -5)}") - builtin print - builtin print -P "%F{33}▓▒░ %F{34}Successfully installed %F{160}(%F{33}z-shell/zi%F{160})%f%b\n" - builtin print -rl -- "${git_refs[@]}" - else - builtin print -P "%F{160}▓▒░ The clone has failed…%f%b" - builtin print -P "%F{160}▓▒░ %F{33} Please report the issue: %F{226}https://github.com/z-shell/zi/issues/new%f%b" - return 1 - fi + if (( ! $+commands[git] )); then + _zi_err "git is required to install Zi but was not found in PATH." + return 1 fi + _zi_check_stream || return 1 + + # A private, unpredictable directory. The progress filter is downloaded and + # then executed, so it must never live at a fixed, world-writable path where + # another user could pre-place a file for us to run. + tmp_dir="$(command mktemp -d "${TMPDIR:-/tmp}/zi-loader.XXXXXX" 2>/dev/null)" || { + _zi_err "could not create a private temporary directory." + return 1 + } + show_process="${tmp_dir}/git-process-output.zsh" + process_url="https://raw.githubusercontent.com/z-shell/zi/main/lib/zsh/git-process-output.zsh" + + # The filter is cosmetic. If it cannot be fetched, fall back to plain output + # rather than failing the install. + if _zi_fetch "$process_url" > "$show_process" && [[ -s $show_process ]]; then + command chmod u+x "$show_process" + else + show_process="" + fi + + (( $+commands[clear] )) && command clear + builtin print -P "%F{33}▓▒░ %F{160}Installing interactive & feature-rich plugin manager (%F{33}z-shell/zi%F{160})%f%b…\n" + + if command mkdir -p "${ZI[BIN_DIR]}"; then + # --filter=blob:none keeps the clone small without making it shallow. + # zi.zsh derives ZI[VERSION] from `git describe --tags`, which a --depth=1 + # clone would degrade to a bare short SHA. + command git clone --verbose --progress \ + --filter=blob:none --single-branch \ + --branch "${ZI[STREAM]}" "${ZI[REPOSITORY]}" "${ZI[BIN_DIR]}" \ + |& { [[ -n $show_process ]] && command "$show_process" || command cat; } + clone_status=${pipestatus[1]} + else + _zi_err "could not create ${(qqq)ZI[BIN_DIR]}" + clone_status=1 + fi + + command rm -rf -- "$tmp_dir" + + if (( clone_status != 0 )) || [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + builtin print -P "%F{160}▓▒░ The clone has failed…%f%b" + builtin print -P "%F{160}▓▒░ %F{33} Please report the issue: %F{226}https://github.com/z-shell/zi/issues/new%f%b" + return 1 + fi + + # Scope the permission fix to the clone. ZI[HOME_DIR] also holds plugins, + # snippets, and other user data that this loader does not own. + command chmod -R go-w "${ZI[BIN_DIR]}" + git_refs=("${(f@)$(builtin cd -q "${ZI[BIN_DIR]}" && command git log --color --graph --abbrev-commit \ + --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' | command head -5)}") + builtin print + builtin print -P "%F{33}▓▒░ %F{34}Successfully installed %F{160}(%F{33}z-shell/zi%F{160})%f%b\n" + builtin print -rl -- "${git_refs[@]}" return 0 } # Source zi.zsh, bootstrapping first if needed. +# +# zi.zsh's top level makes intentional global changes: it sets AUTO_CD when +# ZI[CDPATH_DIR] exists and marks path, manpath, cdpath, mailpath, fpath, and +# logpath as exported and unique. Wrapping the source in `emulate -L zsh` would +# localize those to this function and silently discard them, so instead only +# the options that would corrupt zi.zsh's own parsing are neutralized, then +# restored to the caller's values. _zi_source() { - builtin emulate -L zsh - if [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - builtin source "${ZI[BIN_DIR]}/zi.zsh" - else + local -A caller_opts + local opt + local -a guard=( + sh_word_split ksh_arrays ksh_glob glob_subst rc_expand_param + err_exit err_return no_unset warn_create_global + ) + integer src_status + + if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then _zi_setup || return 1 - # Guard: if setup succeeded but zi.zsh is still missing, don't recurse - if [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - builtin source "${ZI[BIN_DIR]}/zi.zsh" - else + # Guard: if setup reported success but zi.zsh is still missing, do not recurse. + if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + _zi_err "setup reported success but ${(qqq)ZI[BIN_DIR]}/zi.zsh is missing." return 1 fi fi + + for opt in "${guard[@]}"; do + caller_opts[$opt]="${options[$opt]}" + done + builtin setopt no_sh_word_split no_ksh_arrays no_ksh_glob no_glob_subst \ + no_rc_expand_param no_err_exit no_err_return unset no_warn_create_global + + builtin source "${ZI[BIN_DIR]}/zi.zsh" + src_status=$? + + for opt in "${guard[@]}"; do + options[$opt]="${caller_opts[$opt]}" + done + + (( src_status == 0 )) || _zi_err "sourcing zi.zsh failed with status ${src_status}." + return "$src_status" } -# Load zpmod module if built. +# Load the zpmod module if it has been built. _zi_pmod() { builtin emulate -L zsh - if [[ -f "${ZI[ZMODULES_DIR]}/zpmod/Src/zi/zpmod.so" ]]; then - module_path+=( "${ZI[ZMODULES_DIR]}/zpmod/Src" ) - zmodload zi/zpmod 2>/dev/null + local module_dir="${ZI[ZMODULES_DIR]:-${ZI[HOME_DIR]}/zmodules}/zpmod/Src" + [[ -f "${module_dir}/zi/zpmod.so" ]] || return 0 + + typeset -gU module_path + module_path+=( "$module_dir" ) + if ! zmodload zi/zpmod 2>/dev/null; then + [[ ${ZI[MUTE_WARNINGS]} == 1 ]] || + _zi_err "zpmod.so is present but zmodload zi/zpmod failed; rebuild it with \`zi module build\`." + return 1 fi return 0 } -# Register Zi completion if the completion system is active. +# Register the Zi completion if the completion system is already active. _zi_comps() { builtin emulate -L zsh - if (( ${+_comps} )); then - (( ${+_comps[zi]} )) || _comps[zi]=_zi - fi + (( ${+_comps} )) || return 0 + (( ${+_comps[zi]} )) || _comps[zi]=_zi return 0 } # ── Entry Point ─────────────────────────────────────────────────────────────── zzinit() { - { - _zi_source && _zi_comps && _zi_pmod - } always { - unset -f _zi_fetch _zi_setup _zi_source _zi_comps _zi_pmod zzinit 2>/dev/null - } + integer status_source status_comps status_pmod + + # Sourcing Zi is the only hard prerequisite. Completion registration and the + # optional module are independent: neither should be skipped because the + # other reported a problem. + _zi_source + status_source=$? + if (( status_source != 0 )); then + _zi_err "Zi was not loaded. Fix the problem above, then run \`zzinit\` again." + return "$status_source" + fi + + _zi_comps + status_comps=$? + _zi_pmod + status_pmod=$? + + # Helpers are only removed on success so a failed run stays retryable. + unset -f _zi_err _zi_fetch _zi_check_stream _zi_setup _zi_source _zi_comps _zi_pmod zzinit 2>/dev/null + + (( status_comps == 0 && status_pmod == 0 )) } diff --git a/tests/installers.sh b/tests/installers.sh index 48a0a27..908c425 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -97,6 +97,145 @@ test_init_defaults_are_single_arguments() { pass "init defaults preserve argument and value boundaries" } +test_init_preserves_caller_options() { + values_log="${TMP_ROOT}/init-option-values" + + # zi.zsh's top level deliberately sets AUTO_CD and marks the path arrays + # exported/unique. Wrapping the source in `emulate -L zsh` would localize + # those to the loader's own function and silently discard them, so the loader + # guards only the options that would corrupt parsing and restores the + # caller's values afterwards. + zsh -f -c ' + typeset -ghA ZI + ZI[HOME_DIR]="$3/opt-home" + ZI[BIN_DIR]="$3/opt-home/bin" + ZI[CDPATH_DIR]="$3/opt-home/cd_path" + command mkdir -p "${ZI[BIN_DIR]}" "${ZI[CDPATH_DIR]}" + + # A stand-in for zi.zsh that reproduces the two behaviours that matter: + # it needs default word splitting, and its global effects must survive. + print -r -- " + typeset -g SAW_SPLIT=\${options[sh_word_split]} + typeset -g SAW_KSHARRAYS=\${options[ksh_arrays]} + builtin setopt auto_cd + typeset -gxU path PATH + " > "${ZI[BIN_DIR]}/zi.zsh" + + setopt sh_word_split ksh_arrays + source "$1" + zzinit + + { + print -r -- "caller_split:${options[sh_word_split]}" + print -r -- "caller_ksharrays:${options[ksh_arrays]}" + print -r -- "inner_split:${SAW_SPLIT}" + print -r -- "inner_ksharrays:${SAW_KSHARRAYS}" + print -r -- "autocd:${options[autocd]}" + } >"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" "${TMP_ROOT}" + + # The caller keeps the options it had. + contains "${values_log}" 'caller_split:on' + contains "${values_log}" 'caller_ksharrays:on' + # zi.zsh is sourced with sane parsing options regardless of the caller. + contains "${values_log}" 'inner_split:off' + contains "${values_log}" 'inner_ksharrays:off' + # zi.zsh's intentional global effect survives the loader. + contains "${values_log}" 'autocd:on' + pass "loader guards parsing options without discarding Zi's global effects" +} + +test_init_history_opt_out() { + values_log="${TMP_ROOT}/init-history-values" + history_home="${TMP_ROOT}/history-home" + command mkdir -p "${history_home}" + + zsh -f -c ' + export HOME="$3" + export XDG_STATE_HOME="$3/state" + typeset -ghA ZI + ZI[LOADER_HISTORY]=0 + source "$1" + { + print -r -- "histfile:${HISTFILE:-}" + print -r -- "statedir:$([[ -d $XDG_STATE_HOME/zsh ]] && print present || print absent)" + } >"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" "${history_home}" + + contains "${values_log}" 'histfile:' + contains "${values_log}" 'statedir:absent' + pass "ZI[LOADER_HISTORY]=0 suppresses history defaults and filesystem writes" +} + +test_init_rejects_invalid_stream() { + values_log="${TMP_ROOT}/init-stream-values" + err_log="${TMP_ROOT}/init-stream-err" + + # An option-like ZI[STREAM] must never reach `git clone --branch`. + zsh -f -c ' + typeset -ghA ZI + ZI[HOME_DIR]="$4/stream-home" + ZI[BIN_DIR]="$4/stream-home/bin" + ZI[STREAM]="--upload-pack=touch $4/stream-pwned" + source "$1" + zzinit 2>"$3" + print -r -- "status:$?" >"$2" + print -r -- "retryable:${+functions[zzinit]}" >>"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" "${err_log}" "${TMP_ROOT}" + + contains "${values_log}" 'status:1' + # A failed run keeps zzinit defined so the user can fix and retry. + contains "${values_log}" 'retryable:1' + contains "${err_log}" 'not a valid ref name' + [ -e "${TMP_ROOT}/stream-pwned" ] && fail "invalid ZI[STREAM] reached git clone" + pass "invalid ZI[STREAM] is rejected before reaching git" +} + +test_init_progress_filter_url() { + # The loader downloads this file and executes it. A 404 previously aborted + # every clean install with no diagnostic, so the path is asserted here. + contains "${ROOT}/public/zsh/init.zsh" \ + 'https://raw.githubusercontent.com/z-shell/zi/main/lib/zsh/git-process-output.zsh' + pass "progress filter URL points at the published path" +} + +test_init_uses_private_tempdir() { + # A fixed ${TMPDIR}/zi path would let another user pre-place the executable + # that the loader downloads, chmods, and runs. + if grep -F '${TMPDIR:-/tmp}/zi"' "${ROOT}/public/zsh/init.zsh" >/dev/null 2>&1; then + fail "loader uses a predictable temporary directory" + fi + contains "${ROOT}/public/zsh/init.zsh" 'mktemp -d' + pass "loader downloads the progress filter into a private temporary directory" +} + +test_init_xdg_paths_are_strict() { + values_log="${TMP_ROOT}/init-xdg-values" + xdg_home="${TMP_ROOT}/xdg-home" + # The legacy directories exist AND the XDG variables are set. zi.zsh's own + # fallbacks prefer the legacy paths in exactly this situation, so the loader + # must assign the XDG values itself. + command mkdir -p "${xdg_home}/.cache" "${xdg_home}/.config" \ + "${TMP_ROOT}/xdg-cache" "${TMP_ROOT}/xdg-config" + + zsh -f -c ' + export HOME="$3" + export XDG_CACHE_HOME="$4" + export XDG_CONFIG_HOME="$5" + typeset -ghA ZI + source "$1" + { + print -r -- "cache:${ZI[CACHE_DIR]}" + print -r -- "config:${ZI[CONFIG_DIR]}" + } >"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" \ + "${xdg_home}" "${TMP_ROOT}/xdg-cache" "${TMP_ROOT}/xdg-config" + + contains "${values_log}" "cache:${TMP_ROOT}/xdg-cache/zi" + contains "${values_log}" "config:${TMP_ROOT}/xdg-config/zi" + pass "loader keeps strict XDG cache and config paths" +} + write_fake_tools() { FAKE_BIN="${TMP_ROOT}/bin" command mkdir -p "${FAKE_BIN}" @@ -378,6 +517,12 @@ test_sync_init() { check_syntax check_checksums test_init_defaults_are_single_arguments +test_init_preserves_caller_options +test_init_history_opt_out +test_init_rejects_invalid_stream +test_init_progress_filter_url +test_init_uses_private_tempdir +test_init_xdg_paths_are_strict write_fake_tools test_loader_install test_xdg_data_home_install From b5081fa2d2b70dd4c50fc2ee40c17548fc3ed87d Mon Sep 17 00:00:00 2001 From: Sal Date: Fri, 28 Aug 2026 04:41:40 +0100 Subject: [PATCH 2/2] fix(paths): reconcile loader with core resolver --- docs/README.md | 56 ++++++-------- public/checksum.txt | 4 +- public/sh/install.sh | 58 +++++++++++++-- public/zsh/init.zsh | 104 +++++++++++++++++--------- tests/installers.sh | 173 ++++++++++++++++++++++++++++++++++++------- 5 files changed, 295 insertions(+), 100 deletions(-) diff --git a/docs/README.md b/docs/README.md index fa2f01d..3d48ca3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,42 +59,30 @@ function and applies defaults; nothing is cloned, sourced, or written until `zzinit` is called. -The loader owns the settings that must exist before Zi does: - -| Setting | Default | Purpose | -| ------------------ | ------------------------------------------- | -------------------------------- | -| `ZI[REPOSITORY]` | `https://github.com/z-shell/zi.git` | Clone source | -| `ZI[STREAM]` | `main` | Branch or tag to clone | -| `ZI[HOME_DIR]` | `${XDG_DATA_HOME:-$HOME/.local/share}/zi` | Working-directory root | -| `ZI[BIN_DIR]` | `${ZI[HOME_DIR]}/bin` | Where `zi.zsh` is cloned | -| `ZI[CACHE_DIR]` | `${XDG_CACHE_HOME:-$HOME/.cache}/zi` | Cache root, strict XDG | -| `ZI[CONFIG_DIR]` | `${XDG_CONFIG_HOME:-$HOME/.config}/zi` | Config root, strict XDG | -| `ZI[MUTE_WARNINGS]`| `0` | Read directly by user config | - -`ZI[CACHE_DIR]` and `ZI[CONFIG_DIR]` are deliberately kept here rather than -left to `zi.zsh`. Zi does not currently resolve these two XDG-first: it prefers -`$HOME/.cache` and `$HOME/.config` whenever those directories exist and only -consults `XDG_CACHE_HOME`/`XDG_CONFIG_HOME` otherwise. Removing them today -would relocate the cache of every user who sets the XDG variables while still -having the legacy directories present. - -Zi is adopting XDG-first resolution. Once that is released these two -assignments become redundant rather than wrong, since both sides resolve to the -same paths. Remove them only after the released `zi.zsh` resolves both -XDG-first, and keep `test_init_xdg_paths_are_strict` either way. - -Every other `ZI[...]` key is owned by `zi.zsh` and derived from those values -with identical definitions. Set one in `.zshrc` before sourcing the loader to -override it; do not add a duplicate default to the loader. See the +The loader owns only the settings that must exist before Zi does: + +| Setting | Default | Purpose | +| ------------------- | -------------------------------------------- | ---------------------------- | +| `ZI[REPOSITORY]` | `https://github.com/z-shell/zi.git` | Clone source | +| `ZI[STREAM]` | `main` | Branch or tag to clone | +| `ZI[HOME_DIR]` | Legacy home, otherwise XDG data `zi` root | Working-directory root | +| `ZI[BIN_DIR]` | `${ZI[HOME_DIR]}/bin` | Where `zi.zsh` is cloned | +| `ZI[MUTE_WARNINGS]` | `0` | Loader warning control | + +The loader mirrors Zi core's home-resolution contract because it must find or +clone `zi.zsh` before core can run. An explicit `ZI[HOME_DIR]` wins. A +recognized legacy `$HOME/.zi` installation stays active. Otherwise the loader +uses `${XDG_DATA_HOME}/zi` when `XDG_DATA_HOME` is absolute, or +`$HOME/.local/share/zi` when it is unset, empty, or relative. When both homes +contain Zi data, an explicit or unique existing `BIN_DIR` identity selects the +matching home; otherwise the conservative fallback is the legacy home. No +automatic move or merge occurs. + +`ZI[CACHE_DIR]`, `ZI[CONFIG_DIR]`, and every other Zi path are owned and +derived by `zi.zsh`. Set one in `.zshrc` before sourcing the loader to override +it; do not add a duplicate default to the loader. See the [customization guide](https://wiki.zshell.dev/docs/guides/customization#customizing-paths). -`ZI[HOME_DIR]` is the one deliberate divergence: the loader defaults to the XDG -path, while `zi.zsh`'s current fallback for an unset value is `${HOME}/.zi`. -The loader always assigns the value before `zi.zsh` runs, so they never -disagree within a session, but a direct `source zi.zsh` without the loader uses -the other layout. Zi's in-progress XDG-first work is expected to close this -gap; the loader already uses the intended destination. - One loader-only toggle exists: | Setting | Default | Purpose | diff --git a/public/checksum.txt b/public/checksum.txt index fc7b1d2..ae73b25 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,4 +1,4 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh -3fe6363f38e4b7f937300d001b4a3917d8357688facc461f0dc23e1f82d71231 public/sh/install.sh +6e753a0314ba9f2e4f0a2c429798ca3767af4fb94c669e6923d161d6af7aa745 public/sh/install.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh -87ef5849be6f3860a464390b660371a59ae2ff8f9c399cd703fc4aeaa6122478 public/zsh/init.zsh +e5db3eb099daf0098bed263f4f1960adc9d3d0074a19260fae7ab3df8bf08573 public/zsh/init.zsh diff --git a/public/sh/install.sh b/public/sh/install.sh index 626f530..6edb7d9 100755 --- a/public/sh/install.sh +++ b/public/sh/install.sh @@ -102,8 +102,27 @@ fetch_to_file() { return 1 } +is_absolute_path() { + case "${1-}" in + /*) return 0 ;; + *) return 1 ;; + esac +} + +zi_home_has_installation() { + [ -f "$1/bin/zi.zsh" ] || + [ -d "$1/plugins" ] || + [ -d "$1/snippets" ] || + [ -d "$1/completions" ] || + [ -d "$1/zmodules" ] +} + if [ "${AOPT}" = loader ]; then - ZI_CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/zi" + if is_absolute_path "${XDG_CONFIG_HOME-}"; then + ZI_CONFIG_DIR="${XDG_CONFIG_HOME}/zi" + else + ZI_CONFIG_DIR="${HOME}/.config/zi" + fi loader_tmp="${WORKDIR}/init.zsh.tmp" command mkdir -p "${ZI_CONFIG_DIR}" set +e @@ -124,7 +143,30 @@ if [ "${AOPT}" = loader ]; then fi if [ -z "${ZI_HOME-}" ]; then - ZI_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zi" + if is_absolute_path "${XDG_DATA_HOME-}"; then + _zi_data_base="${XDG_DATA_HOME}" + else + _zi_data_base="${HOME}/.local/share" + fi + _zi_legacy_home="${HOME}/.zi" + _zi_xdg_home="${_zi_data_base}/zi" + _zi_legacy_present=0 + _zi_xdg_present=0 + zi_home_has_installation "${_zi_legacy_home}" && _zi_legacy_present=1 + zi_home_has_installation "${_zi_xdg_home}" && _zi_xdg_present=1 + + if [ "${_zi_legacy_present}" -eq 1 ] && [ "${_zi_xdg_present}" -eq 1 ]; then + if [ -f "${_zi_xdg_home}/bin/zi.zsh" ] && [ ! -f "${_zi_legacy_home}/bin/zi.zsh" ]; then + ZI_HOME="${_zi_xdg_home}" + else + ZI_HOME="${_zi_legacy_home}" + printf '%s\n' "Zi installer: both legacy and XDG homes were detected; retaining ${ZI_HOME}. Set ZI_HOME explicitly to select another root. No data was moved." >&2 + fi + elif [ "${_zi_legacy_present}" -eq 1 ]; then + ZI_HOME="${_zi_legacy_home}" + else + ZI_HOME="${_zi_xdg_home}" + fi fi if [ -z "${ZI_BIN_DIR_NAME-}" ]; then @@ -133,7 +175,7 @@ fi if ! test -d "${ZI_HOME}"; then command mkdir -p "${ZI_HOME}" - command chmod go-w "${ZI_HOME}" + command chmod 700 "${ZI_HOME}" fi if ! command -v git >/dev/null 2>&1; then @@ -221,9 +263,15 @@ EOF fi if [ "${AOPT}" = loader ] && [ "${ZOPT}" != skip ]; then command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -if [[ -r "\${XDG_CONFIG_HOME:-\${HOME}/.config}/zi/init.zsh" ]]; then - source "\${XDG_CONFIG_HOME:-\${HOME}/.config}/zi/init.zsh" && zzinit +if [[ -n \${XDG_CONFIG_HOME:-} && \${XDG_CONFIG_HOME} == /* ]]; then + ZI_LOADER_CONFIG_HOME="\${XDG_CONFIG_HOME}/zi" +else + ZI_LOADER_CONFIG_HOME="\${HOME}/.config/zi" +fi +if [[ -r "\${ZI_LOADER_CONFIG_HOME}/init.zsh" ]]; then + source "\${ZI_LOADER_CONFIG_HOME}/init.zsh" && zzinit fi +unset ZI_LOADER_CONFIG_HOME EOF printf '%s\n' "▓▒░ Loader added" fi diff --git a/public/zsh/init.zsh b/public/zsh/init.zsh index 4f81a2d..0847867 100755 --- a/public/zsh/init.zsh +++ b/public/zsh/init.zsh @@ -11,9 +11,15 @@ # Sourcing this file only defines zzinit(). Nothing is cloned, sourced, or # written until zzinit() is called: # -# if [[ -r "${XDG_CONFIG_HOME:-${HOME}/.config}/zi/init.zsh" ]]; then -# source "${XDG_CONFIG_HOME:-${HOME}/.config}/zi/init.zsh" && zzinit +# if [[ -n ${XDG_CONFIG_HOME:-} && $XDG_CONFIG_HOME == /* ]]; then +# ZI_LOADER_CONFIG_HOME="$XDG_CONFIG_HOME/zi" +# else +# ZI_LOADER_CONFIG_HOME="$HOME/.config/zi" # fi +# if [[ -r "$ZI_LOADER_CONFIG_HOME/init.zsh" ]]; then +# source "$ZI_LOADER_CONFIG_HOME/init.zsh" && zzinit +# fi +# unset ZI_LOADER_CONFIG_HOME # # Documented global effects of zzinit(): # - sources zi.zsh, which owns its own documented global effects @@ -33,23 +39,62 @@ typeset -ghA ZI # clone and where. See https://wiki.zshell.dev/docs/guides/customization : "${ZI[REPOSITORY]:=https://github.com/z-shell/zi.git}" : "${ZI[STREAM]:=main}" -: "${ZI[HOME_DIR]:=${XDG_DATA_HOME:-$HOME/.local/share}/zi}" -: "${ZI[BIN_DIR]:=${ZI[HOME_DIR]}/bin}" - -# Strict XDG placement. These are kept because zi.zsh does not currently -# resolve these two XDG-first: it prefers $HOME/.cache and $HOME/.config -# whenever those exist and only consults XDG_CACHE_HOME / XDG_CONFIG_HOME -# otherwise. Dropping them today would silently relocate the cache of every -# user who sets the XDG variables while still having the legacy directories -# present. -# -# Zi is adopting XDG-first resolution. Once released, these two assignments -# become redundant rather than wrong: they resolve to the same paths, so the -# loader keeps working unchanged. Remove them only after the released zi.zsh -# resolves both XDG-first, and keep the regression test that pins the expected -# paths either way. -: "${ZI[CACHE_DIR]:=${XDG_CACHE_HOME:-$HOME/.cache}/zi}" -: "${ZI[CONFIG_DIR]:=${XDG_CONFIG_HOME:-$HOME/.config}/zi}" + +# The loader needs HOME_DIR and BIN_DIR before Zi exists so it knows where to +# find or clone zi.zsh. Mirror Zi's home-resolution contract exactly, without +# creating directories: explicit values win, a recognized legacy home stays +# active, and fresh installs use an absolute XDG data base or its fallback. +# Cache and config remain unset here and are resolved by zi.zsh itself. +() { + builtin emulate -L zsh + + local data_base legacy_home xdg_home marker requested_bin="${ZI[BIN_DIR]}" + integer legacy_present=0 xdg_present=0 + + if [[ -n $XDG_DATA_HOME && $XDG_DATA_HOME == /* ]]; then + data_base="$XDG_DATA_HOME" + else + data_base="${HOME}/.local/share" + fi + legacy_home="${HOME}/.zi" + xdg_home="${data_base}/zi" + + if [[ -z ${ZI[HOME_DIR]} ]]; then + for marker in bin/zi.zsh plugins snippets completions zmodules; do + if [[ -e "${legacy_home}/${marker}" ]]; then + legacy_present=1 + break + fi + done + for marker in bin/zi.zsh plugins snippets completions zmodules; do + if [[ -e "${xdg_home}/${marker}" ]]; then + xdg_present=1 + break + fi + done + + if (( legacy_present && xdg_present )); then + if [[ $requested_bin == "${xdg_home}/bin" || $requested_bin == "${xdg_home}/bin/"* ]] || + [[ -z $requested_bin && -e "${xdg_home}/bin/zi.zsh" && ! -e "${legacy_home}/bin/zi.zsh" ]]; then + ZI[HOME_DIR]="$xdg_home" + ZI[HOME_LAYOUT]=ambiguous-xdg + else + ZI[HOME_DIR]="$legacy_home" + ZI[HOME_LAYOUT]=ambiguous-legacy + fi + elif (( legacy_present )); then + ZI[HOME_DIR]="$legacy_home" + ZI[HOME_LAYOUT]=legacy + else + ZI[HOME_DIR]="$xdg_home" + ZI[HOME_LAYOUT]=xdg + fi + elif [[ -z ${ZI[HOME_LAYOUT]} ]]; then + ZI[HOME_LAYOUT]=explicit + fi + + [[ -n ${ZI[BIN_DIR]} ]] || ZI[BIN_DIR]="${ZI[HOME_DIR]}/bin" +} # Retained for compatibility: user configuration and third-party plugins read # this value directly, so it must be defined rather than merely defaulted @@ -57,22 +102,13 @@ typeset -ghA ZI : "${ZI[MUTE_WARNINGS]:=0}" # NOTE ON DEFAULTS -# The settings above are loader policy: they decide what to clone, where it -# lands, and which base directories are used. Zi's current fallback for an -# unset ZI[HOME_DIR] is ${HOME}/.zi, so a direct `source zi.zsh` without this -# loader produces a different layout. The loader always assigns these before -# zi.zsh runs, so the two never disagree within one session. Zi's in-progress -# XDG-first work is expected to close this gap; the loader's XDG values are -# already the intended destination, so no change is needed here when it lands. -# -# Every other ZI[...] key is owned by zi.zsh and derived from the values above, -# with identical definitions. Do not duplicate them here; set them in .zshrc -# before this file is sourced if you want to override one. The full set zi.zsh -# honours: +# Every other ZI[...] key is owned by zi.zsh. Do not duplicate defaults here; +# set a value in .zshrc before this file is sourced if you want to override it. +# The full set zi.zsh honours includes: # -# Paths COMPLETIONS_DIR PLUGINS_DIR SNIPPETS_DIR SERVICES_DIR -# THEMES_DIR ZMODULES_DIR MAN_DIR LOG_DIR MAIL_DIR CDPATH_DIR -# ZCOMPDUMP_PATH ZPFX +# Paths CACHE_DIR CONFIG_DIR COMPLETIONS_DIR PLUGINS_DIR SNIPPETS_DIR +# SERVICES_DIR THEMES_DIR ZMODULES_DIR MAN_DIR LOG_DIR MAIL_DIR +# CDPATH_DIR ZCOMPDUMP_PATH ZPFX # Behaviour OPTIMIZE_OUT_DISK_ACCESSES COMPINIT_OPTS INTERNAL_ALIASES # PKG_OWNER # diff --git a/tests/installers.sh b/tests/installers.sh index 908c425..f27d812 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -209,31 +209,74 @@ test_init_uses_private_tempdir() { pass "loader downloads the progress filter into a private temporary directory" } -test_init_xdg_paths_are_strict() { - values_log="${TMP_ROOT}/init-xdg-values" - xdg_home="${TMP_ROOT}/xdg-home" - # The legacy directories exist AND the XDG variables are set. zi.zsh's own - # fallbacks prefer the legacy paths in exactly this situation, so the loader - # must assign the XDG values itself. - command mkdir -p "${xdg_home}/.cache" "${xdg_home}/.config" \ - "${TMP_ROOT}/xdg-cache" "${TMP_ROOT}/xdg-config" +test_init_path_resolution() { + values_log="${TMP_ROOT}/init-path-values" + cases_root="${TMP_ROOT}/init path cases" + command mkdir -p "${cases_root}" zsh -f -c ' - export HOME="$3" - export XDG_CACHE_HOME="$4" - export XDG_CONFIG_HOME="$5" - typeset -ghA ZI - source "$1" - { - print -r -- "cache:${ZI[CACHE_DIR]}" - print -r -- "config:${ZI[CONFIG_DIR]}" - } >"$2" - ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" \ - "${xdg_home}" "${TMP_ROOT}/xdg-cache" "${TMP_ROOT}/xdg-config" - - contains "${values_log}" "cache:${TMP_ROOT}/xdg-cache/zi" - contains "${values_log}" "config:${TMP_ROOT}/xdg-config/zi" - pass "loader keeps strict XDG cache and config paths" + run_case() ( + builtin emulate -LR zsh + local label="$1" root="$3/$1" expected_bin + command mkdir -p "$root/home" "$root/zdotdir" + typeset -gx HOME="$root/home" + typeset -gx ZDOTDIR="$root/zdotdir" + unset XDG_DATA_HOME XDG_CACHE_HOME XDG_CONFIG_HOME + typeset -ghA ZI + ZI=([LOADER_HISTORY]=0) + + case "$label" in + fresh-spaces) + typeset -gx XDG_DATA_HOME="$root/data root" + ;; + empty) + typeset -gx XDG_DATA_HOME="" + ;; + relative) + typeset -gx XDG_DATA_HOME="relative data" + command mkdir -p "$ZDOTDIR/.zi/plugins" + ;; + legacy-only) + command mkdir -p "$HOME/.zi/plugins" + ;; + xdg-only) + typeset -gx XDG_DATA_HOME="$root/data" + command mkdir -p "$XDG_DATA_HOME/zi/plugins" + ;; + both-external) + typeset -gx XDG_DATA_HOME="$root/data" + command mkdir -p "$HOME/.zi/plugins" "$XDG_DATA_HOME/zi/plugins" + ;; + both-xdg-source) + typeset -gx XDG_DATA_HOME="$root/data" + command mkdir -p "$HOME/.zi/plugins" "$XDG_DATA_HOME/zi/bin" "$XDG_DATA_HOME/zi/plugins" + command touch "$XDG_DATA_HOME/zi/bin/zi.zsh" + ZI[BIN_DIR]="$XDG_DATA_HOME/zi/bin" + ;; + explicit) + typeset -gx XDG_DATA_HOME="$root/data" + ZI[HOME_DIR]="$root/explicit home" + ;; + esac + + source "$2" + print -r -- "$label|${ZI[HOME_DIR]}|${ZI[BIN_DIR]}|${ZI[HOME_LAYOUT]}|${ZI[CACHE_DIR]:-}|${ZI[CONFIG_DIR]:-}" + ) + + for label in fresh-spaces empty relative legacy-only xdg-only both-external both-xdg-source explicit; do + run_case "$label" "$1" "$2" + done + ' zsh "${ROOT}/public/zsh/init.zsh" "${cases_root}" >"${values_log}" + + contains "${values_log}" "fresh-spaces|${cases_root}/fresh-spaces/data root/zi|${cases_root}/fresh-spaces/data root/zi/bin|xdg||" + contains "${values_log}" "empty|${cases_root}/empty/home/.local/share/zi|${cases_root}/empty/home/.local/share/zi/bin|xdg||" + contains "${values_log}" "relative|${cases_root}/relative/home/.local/share/zi|${cases_root}/relative/home/.local/share/zi/bin|xdg||" + contains "${values_log}" "legacy-only|${cases_root}/legacy-only/home/.zi|${cases_root}/legacy-only/home/.zi/bin|legacy||" + contains "${values_log}" "xdg-only|${cases_root}/xdg-only/data/zi|${cases_root}/xdg-only/data/zi/bin|xdg||" + contains "${values_log}" "both-external|${cases_root}/both-external/home/.zi|${cases_root}/both-external/home/.zi/bin|ambiguous-legacy||" + contains "${values_log}" "both-xdg-source|${cases_root}/both-xdg-source/data/zi|${cases_root}/both-xdg-source/data/zi/bin|ambiguous-xdg||" + contains "${values_log}" "explicit|${cases_root}/explicit/explicit home|${cases_root}/explicit/explicit home/bin|explicit||" + pass "loader mirrors the core home resolver and leaves cache and config to Zi" } write_fake_tools() { @@ -376,7 +419,7 @@ test_loader_install() { # shellcheck disable=SC2016 contains "${config}/zi/init.zsh" ': "${ZI[STREAM]:=feature/test}"' # shellcheck disable=SC2016 - contains "${home}/.zshrc" 'source "${XDG_CONFIG_HOME:-${HOME}/.config}/zi/init.zsh" && zzinit' + contains "${home}/.zshrc" 'source "${ZI_LOADER_CONFIG_HOME}/init.zsh" && zzinit' [ -f "${data}/zi/bin/zi.zsh" ] || fail "loader install did not clone Zi into XDG data home" pass "loader install uses XDG paths and branch override" } @@ -397,6 +440,82 @@ test_xdg_data_home_install() { pass "XDG data home install creates parent directories" } +test_legacy_home_install() { + home="${TMP_ROOT}/legacy-home" + data="${TMP_ROOT}/legacy-data" + command mkdir -p "${home}/.zi/plugins" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null + + [ -f "${home}/.zi/bin/zi.zsh" ] || fail "installer did not retain the legacy Zi home" + [ ! -e "${data}/zi/bin/zi.zsh" ] || fail "installer created a parallel XDG installation" + pass "legacy-only install remains in the legacy Zi home" +} + +test_relative_xdg_fallback_install() { + home="${TMP_ROOT}/relative-home" + work="${TMP_ROOT}/relative-work" + command mkdir -p "${home}" "${work}" + + ( + cd "${work}" || exit 1 + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_CONFIG_HOME="relative-config" \ + XDG_DATA_HOME="relative-data" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -a loader -i skip >/dev/null + ) + + [ -f "${home}/.local/share/zi/bin/zi.zsh" ] || fail "relative XDG data value did not use fallback" + [ -f "${home}/.config/zi/init.zsh" ] || fail "relative XDG config value did not use fallback" + [ ! -e "${work}/relative-data" ] || fail "relative XDG data path was created" + [ ! -e "${work}/relative-config" ] || fail "relative XDG config path was created" + pass "relative XDG installer values use specification fallbacks" +} + +test_both_present_install_identity() { + home="${TMP_ROOT}/both-home" + data="${TMP_ROOT}/both-data" + command mkdir -p "${home}/.zi/plugins" "${data}/zi/bin/.git" + printf '%s\n' '# fake zi.zsh' >"${data}/zi/bin/zi.zsh" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null + + [ ! -e "${home}/.zi/bin" ] || fail "installer ignored the existing XDG source identity" + [ -f "${data}/zi/bin/zi.zsh" ] || fail "installer did not retain the XDG source identity" + pass "both-present installer selection follows existing source identity" +} + +test_explicit_home_install() { + home="${TMP_ROOT}/explicit-home" + data="${TMP_ROOT}/explicit-data" + explicit="${TMP_ROOT}/explicit Zi root" + command mkdir -p "${home}" "${data}/zi/plugins" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_HOME="${explicit}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null + + [ -f "${explicit}/bin/zi.zsh" ] || fail "explicit ZI_HOME was not preserved" + pass "explicit installer home wins and preserves spaces" +} + test_standalone_zpmod_delegation() { standalone_dir="${TMP_ROOT}/standalone" home="${TMP_ROOT}/zpmod-home" @@ -522,10 +641,14 @@ test_init_history_opt_out test_init_rejects_invalid_stream test_init_progress_filter_url test_init_uses_private_tempdir -test_init_xdg_paths_are_strict +test_init_path_resolution write_fake_tools test_loader_install test_xdg_data_home_install +test_legacy_home_install +test_relative_xdg_fallback_install +test_both_present_install_identity +test_explicit_home_install test_standalone_zpmod_delegation test_update_valid_zi_clone test_update_rejects_foreign_repo