From ba1e07744e37288ed8a2f8434433e22402437a00 Mon Sep 17 00:00:00 2001 From: fatalerrors Date: Tue, 13 Oct 2026 16:01:13 +0200 Subject: [PATCH] many bugfix, fixed getopt split --- README.md | 7 ++ doc/CHANGELOG.md | 24 +++++ doc/CONTRIBUTING.md | 10 ++ doc/known-bugs.md | 10 ++ profile.d/args.sh | 119 +++++++++++++++++++++++ profile.d/compress.sh | 28 ++++-- profile.d/conf.sh | 4 +- profile.d/debug.sh | 4 +- profile.d/disp.sh | 4 +- profile.d/filefct.sh | 40 +++++--- profile.d/fun.sh | 19 ++-- profile.d/git.sh | 32 +++++-- profile.d/info.sh | 16 +++- profile.d/lang.sh | 12 ++- profile.d/packages.sh | 12 ++- profile.d/processes.sh | 16 ++-- profile.d/prompt.sh | 13 +++ profile.d/pwd.sh | 4 +- profile.d/rain.sh | 6 +- profile.d/ssh.sh | 4 +- profile.d/updates.sh | 8 +- profile.sh | 211 +++++++++++++++++++++++++++++------------ 22 files changed, 478 insertions(+), 125 deletions(-) create mode 100644 profile.d/args.sh diff --git a/README.md b/README.md index cfca782..9d6dfc3 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,13 @@ load time, which exports every key in that section as an environment variable. Unknown keys are silently ignored; all keys are optional — sensible defaults apply when unset. +A line is `key = value` (the key is a variable name; a line that is neither a +section, a comment nor a valid assignment is reported and ignored). Lines starting +with `#` or `;` are comments, and so is the end of a line after a blank and a `#` +(`PROMPT_THEME = dark # my theme`); a value between single or double quotes is +what is inside the quotes, so a `#` can be kept in it. `$VAR` references to +existing environment variables are expanded; backticks and `$(` are removed. + `profile.conf` is listed in `.gitignore` so personal values (API keys, cities, compiler flags, …) are never accidentally staged. Start from the annotated template at `doc/profile.conf.example` (see [section 2.2](#22-initial-configuration)). diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 164bdf5..260b293 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -172,6 +172,30 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). with absolute or `..` paths are refused and extracted without their owners; modules removed upstream and still loaded are reported; git operations no longer change the shell's directory stack. +- All modules: the option parsing no longer uses `xargs`, which broke on + arguments or file names containing a newline, nor `eval`: a new + `profile.d/args.sh` provides `_getopt_split` (and `_trap_restore`), pure bash + helpers; the `nullglob` and trap restorations no longer use `eval` either. +- `profile.sh`: the shell check used `$SHELL` (the login shell: unset under cron + or systemd, unrelated to the running shell) and made the profile refuse to + load; it now tests `BASH_VERSION`; options are only handled when the file is + executed (a script with its own arguments that sources the profile no longer + gets `--install` run, or "Unknown option" and a profile that does not load); + `PROMPT_COMMAND` is no longer replaced (VTE, direnv, `history -a`, ... keep + working; `set_prompt` runs last and the exit status is saved first); + `INTERACTIVE`, exported to the environment, made non-interactive child shells + run the whole interactive start-up; `pathappend` moved existing entries to the + end of `PATH` (demoting `~/.local/bin` set by Debian's `~/.profile`) and root's + `/sbin:/usr/sbin` was added as one unmatchable entry on each load; a symlinked + `profile.sh` was not followed; inline comments in `profile.conf` + (`KEY = value # note`, as the README shows) became part of the value, and a + line that is not `key = value` created a bogus key; the caller's `nullglob` + setting was reset; `--install` added a second line when the profile was + already sourced in another form, and did not escape special characters in the + path; loop variables leaked. +- `lang.sh`: no more "Overriding existing function" warning for locale shortcuts + inherited from a parent shell. `compress.sh`: `mapfile -d` (bash 4.4) replaced, + the profile supports bash 4.3. - `compress.sh`: option flags and loop variables no longer leak into the interactive shell. diff --git a/doc/CONTRIBUTING.md b/doc/CONTRIBUTING.md index 9070016..e14162e 100755 --- a/doc/CONTRIBUTING.md +++ b/doc/CONTRIBUTING.md @@ -119,6 +119,16 @@ brew install shellcheck stdout, returning 0. - Use `getopt` (not `getopts`) for option parsing — it handles long options and `--` correctly. + Rebuild the arguments from its output with the `_getopt_split` helper + (`profile.d/args.sh`), never with `eval set --` or `xargs`: + + ```bash + PARSED=$(getopt -o hv --long help,verbose -n 'mycmd' -- "$@") || return 1 + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" + ``` + To restore traps saved with `trap -p`, use `_trap_restore` (no `eval` either). - Follow existing error-return conventions: 0 = success, 1 = usage error, 2 = bad options, 3 = missing dependency, 4+ = runtime failure. - Prefix all local helper variables with a short unique prefix (e.g. `_taz_`) diff --git a/doc/known-bugs.md b/doc/known-bugs.md index cf37495..552ee65 100755 --- a/doc/known-bugs.md +++ b/doc/known-bugs.md @@ -43,3 +43,13 @@ project and will not be addressed in Bash. - **Status:** Not fixable in Bash; this is a limitation of Windows terminal design. - **Workaround:** Lower the density parameter for better performance, or use a Unix-like environment for optimal speed. + +### The prompt timer replaces an existing `DEBUG` trap + +- **Description:** The execution time shown in the prompt is measured with a `DEBUG` trap + installed when the profile is loaded. A `DEBUG` trap set earlier (bash-preexec and the + tools built on it) is replaced. +- **Cause:** bash hides the `DEBUG` trap from code run by `source`, so the profile cannot + read it to chain to it. +- **Workaround:** Load the profile before such tools. `PROMPT_COMMAND`, on the other hand, + is preserved: `set_prompt` is added to it. diff --git a/profile.d/args.sh b/profile.d/args.sh new file mode 100644 index 0000000..c4c3b35 --- /dev/null +++ b/profile.d/args.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) 2013-2026 Geoffray Levasseur +# Protected by the BSD3 license. Please read bellow for details. +# +# * 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. +# * +# * Neither the name of the copyright holder nor the names +# * of any other contributors may be used to endorse or +# * promote products derived from this software without +# * specific prior written permission. +# * +# * 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 OWNER 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, +# * OF SUCH DAMAGE. +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# Helpers for the option parsing of the commands. The project rule is to never +# use eval: these functions rebuild argument lists in pure bash. + +# Split the output of getopt into the array whose name is $1, without eval and +# without xargs. getopt prints the normalised arguments as one shell-quoted +# line (options bare, every argument in single quotes, a quote inside an +# argument written '\''), for instance: +# -s 'a b' --long 'it'\''s' -- 'file' '' +# Quoted text is taken verbatim (newlines included), an empty argument ('') +# yields an empty element, and nothing is ever evaluated. The usual call is +# local -a _pargs=() +# _getopt_split _pargs "$PARSED" +# set -- "${_pargs[@]}" +# Usage: _getopt_split +_getopt_split() +{ + local -n _gs_out="$1" + local s="$2" tok="" in_tok=0 run + local run_re=$'^[^ \t\n\'\\\\]+' + _gs_out=() + + while [[ -n "$s" ]]; do + case "${s:0:1}" in + "'") + s="${s:1}" + if [[ "$s" == *\'* ]]; then + tok+="${s%%\'*}" + s="${s#*\'}" + else + # Unterminated quote (never produced by getopt): keep the rest + tok+="$s" + s="" + fi + in_tok=1 + ;; + '\') + tok+="${s:1:1}" + s="${s:2}" + in_tok=1 + ;; + ' '|$'\t'|$'\n') + (( in_tok )) && _gs_out+=("$tok") + tok="" + in_tok=0 + s="${s:1}" + ;; + *) + [[ "$s" =~ $run_re ]] || break + run="${BASH_REMATCH[0]}" + tok+="$run" + s="${s:${#run}}" + in_tok=1 + ;; + esac + done + (( in_tok )) && _gs_out+=("$tok") + return 0 +} +export -f _getopt_split + +# Put back traps saved with "trap -p" (one "trap -- 'command' SIGNAL" line per +# trap), without eval. Used to restore the caller's traps after temporarily +# replacing them. An empty argument restores nothing. +# Usage: _trap_restore "$(trap -p INT TERM)" +_trap_restore() +{ + local line + local -a w=() + while IFS= read -r line; do + [[ "$line" == "trap -- "* ]] || continue + _getopt_split w "$line" + # w = (trap -- ) + [[ ${#w[@]} -eq 4 ]] && trap -- "${w[2]}" "${w[3]}" + done <<< "$1" + return 0 +} +export -f _trap_restore +# ------------------------------------------------------------------------------ + + +# EOF diff --git a/profile.d/compress.sh b/profile.d/compress.sh index 6af5fb2..b776487 100644 --- a/profile.d/compress.sh +++ b/profile.d/compress.sh @@ -250,10 +250,13 @@ utaz() return 1 fi # getopt emits a shell-quoted, normalised argument list ("--" terminates the - # options); re-evaluating it restores the arguments with spaces and quotes - # intact. Declared local so flags never leak in from the caller's environment. + # options); _getopt_split rebuilds the arguments from it (spaces and quotes + # intact, no eval). The flags are declared local so that none leaks in from + # the caller's environment. local willrm="" createdir="" nodir="" icase="" csens="" - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -359,7 +362,12 @@ utaz() elif [[ -d "$zitem" ]]; then # Only direct children are considered (no recursion); NUL-separated # so names containing spaces or newlines survive. - mapfile -d '' -t targets < <(find "$zitem" -mindepth 1 -maxdepth 1 -print0 2>/dev/null) + # ("mapfile -d" needs bash 4.4, the profile supports 4.3) + local _zt + targets=() + while IFS= read -r -d '' _zt; do + targets+=("$_zt") + done < <(find "$zitem" -mindepth 1 -maxdepth 1 -print0 2>/dev/null) if [[ ${#targets[@]} -eq 0 ]]; then disp I "Directory ${zitem} is empty, skipping." continue @@ -517,11 +525,13 @@ utaz() # top-level directory (it then acts as the host directory). # nullglob makes the array empty when nothing matches; dotglob # makes hidden entries count, so ".git" alone is not ignored. - local _old_glob - _old_glob=$(shopt -p nullglob dotglob) + local _had_ng=0 _had_dg=0 + shopt -q nullglob && _had_ng=1 + shopt -q dotglob && _had_dg=1 shopt -s nullglob dotglob local contents=( "${dir}"/* ) - eval "$_old_glob" + (( _had_ng )) || shopt -u nullglob + (( _had_dg )) || shopt -u dotglob [[ ${#contents[@]} -eq 1 && -d "${contents[0]}" && ! -L "${contents[0]}" ]] && flatten=1 fi @@ -719,7 +729,9 @@ taz() # See utaz: restore the getopt-normalised argument list, and keep every # flag local so nothing leaks in from the caller's environment. local willrm="" keepall="" verbose="" quiet="" nproc="" complevel="" - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" local compforms=() local _f _list while true; do diff --git a/profile.d/conf.sh b/profile.d/conf.sh index da719f8..c8f44cb 100644 --- a/profile.d/conf.sh +++ b/profile.d/conf.sh @@ -241,7 +241,9 @@ conf_dump() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list, "--" ends options - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/debug.sh b/profile.d/debug.sh index 78921dd..9a45618 100644 --- a/profile.d/debug.sh +++ b/profile.d/debug.sh @@ -89,7 +89,9 @@ settrace() return 1 fi # getopt emits a shell-quoted, normalised argument list ("--" ends options) - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" # First pass: gather the requested action and flags. --on, --off and # --status are mutually exclusive; --force only modifies --on and --off. diff --git a/profile.d/disp.sh b/profile.d/disp.sh index 907a0c6..0e2571f 100644 --- a/profile.d/disp.sh +++ b/profile.d/disp.sh @@ -651,7 +651,9 @@ mdcat() fi # getopt emits a shell-quoted, normalised argument list ("--" ends options) - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) diff --git a/profile.d/filefct.sh b/profile.d/filefct.sh index 728ac13..8725715 100644 --- a/profile.d/filefct.sh +++ b/profile.d/filefct.sh @@ -52,7 +52,9 @@ expandlist() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -90,8 +92,8 @@ expandlist() local item="" result="" matched=0 # Restore the caller's nullglob setting on exit instead of forcing it off. - local _saved_nullglob - _saved_nullglob=$(shopt -p nullglob) + local _had_nullglob=0 + shopt -q nullglob && _had_nullglob=1 shopt -s nullglob # An empty IFS keeps the unquoted expansion below from splitting on spaces # (it would cut "my dir/*.txt" in two) while globbing still happens. @@ -129,7 +131,7 @@ expandlist() unset content done - eval "$_saved_nullglob" + (( _had_nullglob )) || shopt -u nullglob printf '%s\n' "$result" } export -f expandlist @@ -158,7 +160,9 @@ clean() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -289,7 +293,9 @@ rmspc() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -352,8 +358,8 @@ rmspc() (( verb )) && mvopt=(-v) # Restore the caller's nullglob setting on exit instead of forcing it off. - local _saved_nullglob - _saved_nullglob=$(shopt -p nullglob) + local _had_nullglob=0 + shopt -q nullglob && _had_nullglob=1 shopt -s nullglob local f for f in *; do @@ -410,7 +416,7 @@ rmspc() fi done unset f - eval "$_saved_nullglob" + (( _had_nullglob )) || shopt -u nullglob } export -f rmspc # ------------------------------------------------------------------------------ @@ -446,7 +452,9 @@ file_stats() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -701,7 +709,9 @@ findbig() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -812,7 +822,9 @@ findzero() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -933,7 +945,9 @@ finddead() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/fun.sh b/profile.d/fun.sh index 94f31b8..3be7f82 100644 --- a/profile.d/fun.sh +++ b/profile.d/fun.sh @@ -97,11 +97,8 @@ _fun_stop_on_key() kill "$pid" 2>/dev/null } wait "$pid" 2>/dev/null - if [[ -n "$old_int" ]]; then - eval "$old_int" - else - trap - INT - fi + trap - INT + _trap_restore "$old_int" return 0 } export -f _fun_stop_on_key @@ -129,7 +126,9 @@ busy() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -219,7 +218,9 @@ fake_compile() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -421,7 +422,9 @@ hack() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/git.sh b/profile.d/git.sh index 5747d49..adcc61d 100644 --- a/profile.d/git.sh +++ b/profile.d/git.sh @@ -132,7 +132,9 @@ gst() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -180,7 +182,9 @@ ggraph() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" local limit=30 while true; do case "$1" in @@ -232,7 +236,9 @@ gsync() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -299,7 +305,9 @@ gacp() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" local msg="" auto_add=0 case "${GIT_GACP_AUTO_ADD,,}" in 1|true|yes|on) auto_add=1 ;; @@ -423,7 +431,9 @@ greset() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" local clean_ignored=0 while true; do case "$1" in @@ -560,7 +570,9 @@ gwip() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -618,7 +630,9 @@ gprune() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -731,7 +745,9 @@ groot() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" local do_go=0 while true; do case "$1" in diff --git a/profile.d/info.sh b/profile.d/info.sh index 1d1519b..5d69643 100644 --- a/profile.d/info.sh +++ b/profile.d/info.sh @@ -51,7 +51,9 @@ ver() fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -92,7 +94,9 @@ meteo() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -149,7 +153,9 @@ pinfo() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -392,7 +398,9 @@ showinfo() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/lang.sh b/profile.d/lang.sh index 90850dd..563cbd1 100644 --- a/profile.d/lang.sh +++ b/profile.d/lang.sh @@ -109,7 +109,9 @@ setlocale() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -232,8 +234,12 @@ build_locale_shortcuts() continue fi - # Optional collision warning - if declare -F "$fname" >/dev/null 2>&1; then + # Collision warning. A function that already calls setlocale is one of + # these shortcuts, typically inherited through the environment from a + # parent shell that loaded the profile: replacing it is not worth a + # warning at every nested shell. + if declare -F "$fname" >/dev/null 2>&1 && \ + ! declare -f "$fname" 2>/dev/null | grep -q 'setlocale'; then disp W "Overriding existing function '$fname'." fi diff --git a/profile.d/packages.sh b/profile.d/packages.sh index 5faeff9..b01ca1e 100644 --- a/profile.d/packages.sh +++ b/profile.d/packages.sh @@ -134,7 +134,7 @@ pkgs() return 1 fi local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') + _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do @@ -213,7 +213,7 @@ pkgf() return 1 fi local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') + _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do @@ -342,7 +342,9 @@ pkgi() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -462,7 +464,9 @@ pkgr() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/processes.sh b/profile.d/processes.sh index 5cf1a98..cda2097 100644 --- a/profile.d/processes.sh +++ b/profile.d/processes.sh @@ -52,7 +52,7 @@ ppg() fi local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') + _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in @@ -124,7 +124,7 @@ ppu() fi local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') + _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in @@ -174,7 +174,7 @@ ppn() fi local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') + _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in @@ -235,7 +235,7 @@ gpid() fi local -a _pargs=() - readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0') + _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in @@ -356,7 +356,9 @@ ku() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) @@ -506,7 +508,9 @@ kt() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) diff --git a/profile.d/prompt.sh b/profile.d/prompt.sh index 8ecf619..a39dcc7 100644 --- a/profile.d/prompt.sh +++ b/profile.d/prompt.sh @@ -592,6 +592,13 @@ _prompt_ctx_add() fi } +# Store the exit status of the user's last command (first command of a +# PROMPT_COMMAND that also contains other commands, see profile.sh). +_profile_save_status() +{ + _profile_status=$? +} + # ------------------------------------------------------------------------------ # Function triggered internally by bash : defining prompt # Usage: set_prompt @@ -601,6 +608,12 @@ _prompt_ctx_add() set_prompt() { local Last_Command=$? # Must come first: nothing may run before this line + # When other commands precede set_prompt in PROMPT_COMMAND (profile.sh adds + # _profile_save_status first for that case), $? is theirs: use the saved one. + if [[ -n "${_profile_status+x}" ]]; then + Last_Command="$_profile_status" + unset _profile_status + fi local FancyX='\342\234\227' local Checkmark='\342\234\223' diff --git a/profile.d/pwd.sh b/profile.d/pwd.sh index 9209bef..0e3d2ba 100644 --- a/profile.d/pwd.sh +++ b/profile.d/pwd.sh @@ -109,7 +109,9 @@ genpwd() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/rain.sh b/profile.d/rain.sh index 3d4e8d8..4fa5f5d 100644 --- a/profile.d/rain.sh +++ b/profile.d/rain.sh @@ -265,7 +265,7 @@ _rain_leave() printf '\e[0m\e[?25h\e[2J\e[H' [[ -n "$saved_stty" ]] && stty "$saved_stty" trap - TERM INT WINCH - [[ -n "$saved_traps" ]] && eval "$saved_traps" + _trap_restore "$saved_traps" return 0 } @@ -677,7 +677,9 @@ rainbow() frame_sleep=$(_rain_normalize_speed "$_raw_speed") || frame_sleep=0.040 # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) diff --git a/profile.d/ssh.sh b/profile.d/ssh.sh index 94c89d7..4245b8c 100644 --- a/profile.d/ssh.sh +++ b/profile.d/ssh.sh @@ -81,7 +81,9 @@ rmhost() return 1 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in diff --git a/profile.d/updates.sh b/profile.d/updates.sh index 34067f9..1b7e84c 100644 --- a/profile.d/updates.sh +++ b/profile.d/updates.sh @@ -95,7 +95,9 @@ check_updates() return 2 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in @@ -189,7 +191,9 @@ profile_upgrade() return 2 fi # Restore the getopt-normalised (shell-quoted) argument list - eval set -- "$PARSED" + local -a _pargs=() + _getopt_split _pargs "$PARSED" + set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) diff --git a/profile.sh b/profile.sh index d5395ed..956372d 100644 --- a/profile.sh +++ b/profile.sh @@ -35,31 +35,38 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ +# This first test is POSIX on purpose: it runs before anything bash-specific, so +# that sourcing the file from dash, zsh, ... ends with a message instead of +# cascading errors. It checks the running shell: $SHELL is the user's login +# shell, which says nothing about it (and is unset under cron or systemd). +if [ -z "${BASH_VERSION:-}" ]; then + echo "That environment script is designed to be used with bash." >&2 + echo "Please consider using bash to enjoy our features!" >&2 + return 1 2>/dev/null || exit 1 +fi + _profile_is_sourced() { [[ "${BASH_SOURCE[0]}" != "$0" ]] } -_profile_finish() -{ - local rc="${1:-0}" - if _profile_is_sourced; then - return "$rc" - fi - exit "$rc" -} - +# Installation helper: write $2 (the source line) in the file $1, unless that +# file already sources $3 (the profile) in some form. _profile_install_in_file() { local rc_file="$1" local source_line="$2" + local target="$3" [[ -f "$rc_file" ]] || touch "$rc_file" || { printf "[ Error ] Cannot create %s\n" "$rc_file" >&2 return 1 } - if grep -Fqx "$source_line" "$rc_file"; then + # The exact line, or any other sourcing statement naming the same file + # (". ~/p/profile.sh", with other quotes, ...): adding another one would + # load the profile twice. + if grep -Fqx "$source_line" "$rc_file" || _profile_file_sources "$rc_file" "$target"; then printf "[ Info ] Already configured in %s\n" "$rc_file" return 0 fi @@ -145,56 +152,62 @@ _profile_install() fi script_dir=$(dirname "$(realpath -s "${BASH_SOURCE[0]}")") - source_line="source \"$script_dir/profile.sh\"" + # The path goes between double quotes in the rc file: escape what stays + # special there (a plain path is left unchanged, so existing lines keep + # matching) + local escaped_dir="$script_dir" + escaped_dir="${escaped_dir//\\/\\\\}" + escaped_dir="${escaped_dir//\"/\\\"}" + escaped_dir="${escaped_dir//\$/\\\$}" + escaped_dir="${escaped_dir//\`/\\\`}" + source_line="source \"$escaped_dir/profile.sh\"" if (( install_bashrc == 1 )); then - _profile_install_in_file "$HOME/.bashrc" "$source_line" || rc=$? + _profile_install_in_file "$HOME/.bashrc" "$source_line" "$script_dir/profile.sh" || rc=$? fi if (( install_profile == 1 )); then - _profile_install_in_file "$HOME/.profile" "$source_line" || rc=$? + _profile_install_in_file "$HOME/.profile" "$source_line" "$script_dir/profile.sh" || rc=$? fi return "$rc" } -if [[ $# -gt 0 ]]; then - case "$1" in +# Options are only handled when the file is executed: a sourced file receives +# the positional parameters of whoever sources it ("./script --install" sourcing +# the profile must not install anything, nor fail on an option that is not for +# us). +if ! _profile_is_sourced; then + case "${1:-}" in --install) shift _profile_install "$@" - _profile_finish $? + exit $? ;; -h|--help) printf "Usage: source %s\n" "${BASH_SOURCE[0]}" printf " %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" - _profile_finish 0 + exit 0 + ;; + "") ;; *) printf "[ Error ] Unknown option: %s\n" "$1" >&2 - _profile_finish 2 + exit 2 ;; esac -fi -if ! _profile_is_sourced; then printf "[ Warning ] profile.sh is designed to be sourced, not executed directly.\n" >&2 printf "Use: source \"%s\"\n" "$(realpath -s "${BASH_SOURCE[0]}")" >&2 printf "Or run: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" >&2 exit 1 fi -if [[ ! $SHELL =~ bash ]]; then - echo "That environment script is designed to be used with bash being the shell." - echo "Please consider using bash to enjoy our features!" - _profile_finish 1 -fi - # Required for associative arrays (4.0+) and namerefs (4.3+) if ((BASH_VERSINFO[0] < 4)) || [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 3 ]]; then - echo "[ Error ] This profile requires Bash 4.3 or higher." - echo "Current version: $BASH_VERSION" - (return 0 2>/dev/null) && return 1 || exit 1 + echo "[ Error ] This profile requires Bash 4.3 or higher." >&2 + echo "Current version: $BASH_VERSION" >&2 + return 1 fi # ------------------------------------------------------------------------------ @@ -227,6 +240,10 @@ pathremove() # export "$pathvar=$1${!pathvar:+:${!pathvar}}" #} +# Append one directory to a path variable (PATH by default), unless it is +# already in it: an entry that exists keeps its place. Moving it to the end +# would, for instance, demote ~/.local/bin that Debian's ~/.profile puts first, +# so that system programs shadow the user's own ones. pathappend() { [[ -z "$1" ]] && return 0 @@ -235,7 +252,9 @@ pathappend() printf "pathappend: unsafe variable name '%s'\n" "$pathvar" >&2 return 1 } - pathremove "$1" "$pathvar" + case ":${!pathvar}:" in + *":$1:"*) return 0 ;; + esac export "$pathvar=${!pathvar:+${!pathvar}:}$1" } # ------------------------------------------------------------------------------ @@ -260,13 +279,21 @@ parse_conf() # Skip comments and empty lines [[ -z "$key" || "$key" =~ ^[#\;] ]] && continue - # Section Detection: [section_name] - if [[ "$key" =~ ^\[([a-zA-Z0-9_]+)\]$ ]]; then + # Section Detection: [section_name], optionally followed by a comment + if [[ "$key" =~ ^\[([a-zA-Z0-9_]+)\][[:space:]]*([#\;].*)?$ ]]; then current_section="${BASH_REMATCH[1]}" declare -g -A "CONF_$current_section" continue fi + # A key is a variable name: it ends up in "export" (load_conf) and as an + # array subscript. Anything else (a stray line, a typo) is reported. + if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + printf "[ Warning ] %s: invalid configuration line ignored: '%s'\n" \ + "$config_file" "$key" >&2 + continue + fi + # Secure Assignment (if inside a section) if [[ -n "$current_section" ]]; then # Clean the value @@ -274,6 +301,20 @@ parse_conf() value="${value#"${value%%[![:space:]]*}"}" value="${value%$'\r'}" + # A quoted value is what is between the quotes (a trailing comment + # is dropped); an unquoted one ends at an inline comment, which is + # a "#" preceded by a blank ("dark # my theme"). + if [[ "$value" == \"* ]]; then + value="${value#\"}" + value="${value%%\"*}" + elif [[ "$value" == \'* ]]; then + value="${value#\'}" + value="${value%%\'*}" + else + value="${value%%[[:space:]]#*}" + value="${value%"${value##*[![:space:]]}"}" + fi + # Protect against command injection by disallowing certain characters in keys value="${value//\`/}" value="${value//\$\(/}" @@ -298,10 +339,6 @@ parse_conf() # If envsubst is unavailable, $VAR references are left as-is. fi - # Strip quotes (handling both " and ') - value="${value%\"}"; value="${value#\"}" - value="${value%\'}"; value="${value#\'}" - # Use a nameref for safe, eval-free assignment local -n current_array="CONF_$current_section" # shellcheck disable=SC2034 # Dynamic var creation @@ -323,6 +360,7 @@ load_alias() # Create a nameref to the section array local -n current_aliases="$section_name" + local key # Iterate safely over the keys of the associative array for key in "${!current_aliases[@]}"; do @@ -351,6 +389,7 @@ load_conf() [[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 0 local -n current_vars="$section_name" + local key for key in "${!current_vars[@]}"; do # Export the key/value pair as a standard shell variable @@ -367,32 +406,41 @@ load_conf() # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ -# Store script's path (realpath -s resolve symlinks if profile.sh is a symlink) -# Because we're more likely to be sourced, we use BASH_SOURCE to get the path -# of the sourced file instead of $0 -if [[ -z "$PROFILE_PATH" ]]; then - MYPATH=$(dirname "$(realpath -s "${BASH_SOURCE[0]}")") +# Store the script's path. Because we're more likely to be sourced, we use +# BASH_SOURCE to get the path of the sourced file instead of $0. If profile.sh +# itself is a symbolic link, it is followed to find the real installation +# (profile.d, version, ...); a symlinked directory is kept as the user sees it +# (realpath -s). +if [[ -z "${PROFILE_PATH:-}" ]]; then + _profile_src="${BASH_SOURCE[0]}" + [[ -L "$_profile_src" ]] && _profile_src=$(readlink -f -- "$_profile_src") + MYPATH=$(dirname "$(realpath -s -- "$_profile_src")") + unset _profile_src else MYPATH="$PROFILE_PATH" fi export MYPATH if [[ ! -e "$MYPATH/profile.sh" ]]; then - echo "[ Warning ] Path detection failed, trying to use pwd..." + echo "[ Warning ] Path detection failed, trying to use pwd..." >&2 MYPATH=$(pwd) if [[ ! -e "$MYPATH/profile.sh" ]]; then - echo "[ Error ] Unable to determine installation path, pretty much nothing will work." + echo "[ Error ] Unable to determine installation path, pretty much nothing will work." >&2 fi fi -if [[ ! -s "$MYPATH/version" ]]; then - echo "[ Warning ] Unable to determine running profile version; your installation might be broken." +PROFVERSION="" +if [[ -s "$MYPATH/version" ]]; then + IFS= read -r PROFVERSION < "$MYPATH/version" + PROFVERSION="${PROFVERSION%$'\r'}" +else + echo "[ Warning ] Unable to determine running profile version; your installation might be broken." >&2 fi -PROFVERSION=$(cat "$MYPATH"/version) export PROFVERSION # Build PATH environment variable if [[ $EUID -eq 0 ]]; then - pathappend /sbin:/usr/sbin + pathappend /sbin + pathappend /usr/sbin fi [[ -d ~/bin ]] && pathappend ~/bin [[ -d ~/.local/bin ]] && pathappend ~/.local/bin @@ -407,22 +455,31 @@ fi load_conf system # Load Bash system behavior configuration (history, pager, etc.) load_conf general # General purpose configuration (compilation flags, etc.) -# Load module scripts +# Load module scripts. nullglob is only needed for the expansion: the caller's +# setting is put back afterwards instead of being forced off. +_profile_ng=0 +shopt -q nullglob && _profile_ng=1 shopt -s nullglob -for script in "$MYPATH/profile.d/"*.sh; do - if [[ -f "$script" && -r "$script" ]]; then +for _profile_script in "$MYPATH/profile.d/"*.sh; do + if [[ -f "$_profile_script" && -r "$_profile_script" ]]; then # shellcheck source=/dev/null - . "$script" || printf "[ Warning ] Failed to source module: %s\n" "$script" >&2 + . "$_profile_script" || printf "[ Warning ] Failed to source module: %s\n" "$_profile_script" >&2 fi done -shopt -u nullglob +unset _profile_script +(( _profile_ng )) || shopt -u nullglob -# Interactive shell detection, two methods available each one of those might have different result -# depending on distribution -#shopt -q login_shell && INTERACTIVE=1 -[[ $- == *i* ]] && export INTERACTIVE=1 +# Interactive shell detection. INTERACTIVE is exported for the modules, so it +# is reset when the shell is not interactive: a non-interactive child of an +# interactive shell would otherwise inherit INTERACTIVE=1 and run the whole +# interactive part (banner, prompt, update check) inside scripts. +if [[ $- == *i* ]]; then + export INTERACTIVE=1 +else + unset INTERACTIVE +fi -if [[ $INTERACTIVE ]]; then +if [[ -n "${INTERACTIVE:-}" ]]; then # Load custom bash completions shopt -s nullglob for _compl in "$MYPATH/profile.d/bash-completion/"*.sh; do @@ -430,14 +487,43 @@ if [[ $INTERACTIVE ]]; then [[ -f "$_compl" && -r "$_compl" ]] && . "$_compl" done unset _compl - shopt -u nullglob + (( _profile_ng )) || shopt -u nullglob # Aliases - load_alias aliases + load_alias aliases - # Define PS1 + # Define PS1. The timer hook is a DEBUG trap: it replaces any DEBUG trap set + # before (it cannot even be read from a sourced file, bash hides it there), + # so load this profile before tools such as bash-preexec. trap 'timer_start' DEBUG - PROMPT_COMMAND='set_prompt' + + # set_prompt is added to PROMPT_COMMAND instead of replacing it, so that + # what exists (VTE's directory reporting, direnv, "history -a", ...) keeps + # working. It has to read the exit status of the user's last command, which + # any command running before it resets: _profile_save_status stores it + # first, set_prompt (which uses it) runs last. It must also run last for the + # timer: the DEBUG trap fires for every command of PROMPT_COMMAND, and the + # elapsed time is measured from the first one of the user's command. + # Nothing changes when PROMPT_COMMAND is empty or already has set_prompt. + case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in + "declare -a"*) + # Array form (bash 5.1+) + if [[ " ${PROMPT_COMMAND[*]} " != *" set_prompt "* ]]; then + if (( ${#PROMPT_COMMAND[@]} )); then + PROMPT_COMMAND=(_profile_save_status "${PROMPT_COMMAND[@]}" set_prompt) + else + PROMPT_COMMAND=(set_prompt) + fi + fi + ;; + *) + if [[ -z "${PROMPT_COMMAND:-}" ]]; then + PROMPT_COMMAND='set_prompt' + elif [[ "$PROMPT_COMMAND" != *set_prompt* ]]; then + PROMPT_COMMAND="_profile_save_status;${PROMPT_COMMAND};set_prompt" + fi + ;; + esac # Set default language from DEFAULT_LANG config key (set in [general]). # The value must match one of the alias names defined in SET_LOCALE so that @@ -455,9 +541,10 @@ if [[ $INTERACTIVE ]]; then check_updates -q disp I "Profile version $PROFVERSION loaded..." fi +unset _profile_ng # Cleanup -unset -f _profile_is_sourced _profile_finish _profile_install_in_file _profile_file_sources _profile_install +unset -f _profile_is_sourced _profile_install_in_file _profile_file_sources _profile_install unset -f parse_conf load_alias load_conf unset -f pathremove pathprepend pathappend