diff --git a/.gitignore b/.gitignore index 2755923..fe66656 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,11 @@ __pycache__/ # Tool caches .ruff_cache/ .mypy_cache/ + +# Emacs backups and autosaves +*~ +\#*\# +.\#* +# Vim swap files +*.swp +*.swo diff --git a/bin/bwclaude b/bin/bwclaude index f1d719b..2ab94ea 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -260,18 +260,10 @@ ensure_host_dirs() { # This is intentional for shared accounts. On a personal machine, # run with --init-auth once to bootstrap persistent credentials. build_claude_home_mount() { - # Create intermediate --dir entries if CLAUDE_HOME_DIR is nested under $HOME. - if [[ "${CLAUDE_HOME_DIR}" == "${_HOME}"/* ]]; then - local _rel="${CLAUDE_HOME_DIR#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts - IFS='/' read -ra _parts <<< "${_rel}" - local _i - for ((_i = 0; _i < ${#_parts[@]} - 1; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done - fi + # Create intermediate --dir entries if CLAUDE_HOME_DIR is nested under + # $HOME. CLAUDE_HOME_DIR is the tmpfs mount point, so only its ancestors + # are pre-created. + _emit_home_intermediate_dirs "${CLAUDE_HOME_DIR}" BWRAP_ARGS+=(--tmpfs "${CLAUDE_HOME_DIR}") @@ -339,6 +331,10 @@ build_claude_cache_mount() { # ── Claude env vars ────────────────────────────────────────────── build_claude_env() { + # NOTE: bwclaude is hard-wired for the NSLS-II AIFAPIM/foundry gateway. + # It cannot be used on a personal machine without modifying the foundry + # URL/key logic below to point at a different foundry instance. + # Foundry mode is forced on (see CLAUDE_CODE_USE_FOUNDRY below). # In foundry mode, Claude Code uses ANTHROPIC_FOUNDRY_* env vars and # ignores ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY, so we no longer set @@ -352,7 +348,7 @@ build_claude_env() { echo "Error: bwclaude (foundry mode) requires an API key." >&2 echo "Export one of:" >&2 echo " ANTHROPIC_FOUNDRY_API_KEY= # direct foundry key" >&2 - echo " AIFAPIM_API_KEY= # NSLS-II hermes/AIFAPIM key (preferred)" >&2 + echo " AIFAPIM_API_KEY= # NSLS-II AIFAPIM key (preferred)" >&2 exit 1 fi @@ -373,18 +369,18 @@ build_claude_env() { else echo "Error: bwclaude requires AIFAPIM_HOST (or ANTHROPIC_FOUNDRY_BASE_URL) to be set." >&2 echo "Export one of:" >&2 - echo " AIFAPIM_HOST=hermes.nsls2.bnl.gov # NSLS-II Hermes gateway hostname" >&2 + echo " AIFAPIM_HOST= # NSLS-II AIFAPIM gateway hostname" >&2 echo " ANTHROPIC_FOUNDRY_BASE_URL=https://... # full Foundry base URL override" >&2 exit 1 fi # Forward any host CLAUDE_* env vars (incl. user overrides for verbosity, etc.). local _key - for _key in $(compgen -e); do + while IFS= read -r _key; do if [[ "${_key}" == CLAUDE_* ]]; then BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") fi - done + done < <(compgen -e) # Enforce after the loop so host env cannot override these. BWRAP_ARGS+=(--setenv CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS 1) diff --git a/bin/bwcodex b/bin/bwcodex index dc79b09..441c2e1 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -306,18 +306,10 @@ check_codex_config() { # behaviour for shared accounts. On a personal account using # ChatGPT login, run with --init-auth once to bootstrap persistent auth. build_codex_home_mount() { - # Create intermediate --dir entries if CODEX_HOME_DIR is nested under $HOME. - if [[ "${CODEX_HOME_DIR}" == "${_HOME}"/* ]]; then - local _rel="${CODEX_HOME_DIR#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts - IFS='/' read -ra _parts <<< "${_rel}" - local _i - for ((_i = 0; _i < ${#_parts[@]} - 1; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done - fi + # Create intermediate --dir entries if CODEX_HOME_DIR is nested under + # $HOME. CODEX_HOME_DIR is the tmpfs mount point, so only its ancestors + # are pre-created. + _emit_home_intermediate_dirs "${CODEX_HOME_DIR}" BWRAP_ARGS+=(--tmpfs "${CODEX_HOME_DIR}") @@ -417,11 +409,11 @@ build_codex_env() { # Generic CODEX_* sweep so user-defined overrides flow through. local _key - for _key in $(compgen -e); do + while IFS= read -r _key; do if [[ "${_key}" == CODEX_* ]]; then BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") fi - done + done < <(compgen -e) # --debug wrapper flag: force verbose logging regardless of host env. if [[ "${DEBUG_MODE}" -eq 1 ]]; then diff --git a/bin/bwcopilot b/bin/bwcopilot index 89941da..7348448 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -271,17 +271,9 @@ ensure_host_dirs() { # - command-history-state.json, ide/: transient state build_copilot_home_mount() { # Create intermediate directories for paths under $HOME in the tmpfs. - if [[ "${COPILOT_HOME_DIR}" == "${_HOME}"/* ]]; then - local _rel="${COPILOT_HOME_DIR#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts - IFS='/' read -ra _parts <<< "${_rel}" - local _i - for ((_i = 0; _i < ${#_parts[@]} - 1; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done - fi + # COPILOT_HOME_DIR is the tmpfs mount point, so only its ancestors are + # pre-created. + _emit_home_intermediate_dirs "${COPILOT_HOME_DIR}" # Mount COPILOT_HOME as tmpfs (ephemeral by default) BWRAP_ARGS+=(--tmpfs "${COPILOT_HOME_DIR}") diff --git a/bin/bwopencode b/bin/bwopencode index ff80b16..413d98a 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -372,11 +372,11 @@ build_opencode_env() { pass_through_if_set AIFAPIM_API_KEY local _key - for _key in $(compgen -e); do + while IFS= read -r _key; do if [[ "${_key}" == OPENCODE_* ]]; then BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") fi - done + done < <(compgen -e) } # ═══════════════════════════════════════════════════════════════════ diff --git a/bin/gh-protect-branch b/bin/gh-protect-branch index fce7f03..579019c 100755 --- a/bin/gh-protect-branch +++ b/bin/gh-protect-branch @@ -45,10 +45,15 @@ set -euo pipefail +if ((BASH_VERSINFO[0] < 4)); then + echo "Error: gh-protect-branch requires bash >= 4.0 (found ${BASH_VERSION})." >&2 + exit 1 +fi + RULESET_NAME="nsls2-branch-protection" show_help() { - sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,/^$/{ s/^# \{0,1\}//; p; }' "$0" } APPROVERS_TEAM="" @@ -89,7 +94,8 @@ REPO="$1" BRANCH="${2:-main}" if [[ ! "${REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then - echo "Error: repository must be in 'owner/repo' format (got: ${REPO})" >&2 + echo "Error: repository must be in 'owner/repo' format (e.g. NSLS2/n2snscripts)." >&2 + echo " Got: '${REPO}'" >&2 exit 1 fi diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 2653ce9..3f0d687 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -68,6 +68,12 @@ BWRAP_ARGS=() declare -A _MOUNTED_PREFIXES=() _EXTRA_PATH_DIRS=() +# Cached result of `npm prefix -g` (resolved at most once per run). +# _NPM_PREFIX_RESOLVED flips to 1 after the first lookup; _NPM_PREFIX holds +# the value (empty string if npm is absent or the lookup failed). +_NPM_PREFIX="" +_NPM_PREFIX_RESOLVED=0 + # Sandbox environment SANDBOX_PATH="" SANDBOX_SHELL="" @@ -91,6 +97,18 @@ _GIT_ROOT="" # Shared helper functions # ═══════════════════════════════════════════════════════════════════ +# _version_ge A B +# Return 0 (true) if dotted version A is greater than or equal to B. +# Uses `sort -V` (GNU coreutils, bash 4.1-era and later) so that +# multi-component versions like 0.11.0 sort correctly against 0.5.0 — +# a plain string/integer comparison would mis-order 0.11 vs 0.5. +_version_ge() { + local a="$1" b="$2" + [[ "${a}" == "${b}" ]] && return 0 + # If the lexicographically-smallest version (per sort -V) is B, then A > B. + [[ "$(printf '%s\n%s\n' "${a}" "${b}" | sort -V | head -n1)" == "${b}" ]] +} + # Feature detection by parsing the version string — much cheaper than # grepping --help. # @@ -101,27 +119,18 @@ detect_bwrap_capabilities() { local _bwrap_ver _bwrap_ver="$(bwrap --version)" _bwrap_ver="${_bwrap_ver##* }" # "bubblewrap 0.11.0" -> "0.11.0" - local _bw_major _bw_minor _bw_patch - IFS='.' read -r _bw_major _bw_minor _bw_patch <<< "${_bwrap_ver}" - _bw_patch="${_bw_patch:-0}" # default to 0 if no patch version # --clearenv: Start with an empty environment (added in 0.5.0) # Fallback: manually unset all host env vars with --unsetenv HAS_CLEARENV=0 - if [[ "${_bw_major}" -gt 0 ]] || { [[ "${_bw_major}" -eq 0 ]] && [[ "${_bw_minor}" -ge 5 ]]; }; then - HAS_CLEARENV=1 - fi + _version_ge "${_bwrap_ver}" "0.5.0" && HAS_CLEARENV=1 # Bind over ro-bind: ability to bind-mount on top of a read-only bind # mount (e.g., masking /usr/bin/ssh after --ro-bind /usr /usr). # This works in 0.6.3+; earlier versions fail with "Permission denied". # Fallback: skip binary masking (security reduction — tools remain accessible) HAS_BIND_OVER_RO=0 - if [[ "${_bw_major}" -gt 0 ]] || - { [[ "${_bw_major}" -eq 0 ]] && [[ "${_bw_minor}" -gt 6 ]]; } || - { [[ "${_bw_major}" -eq 0 ]] && [[ "${_bw_minor}" -eq 6 ]] && [[ "${_bw_patch}" -ge 3 ]]; }; then - HAS_BIND_OVER_RO=1 - fi + _version_ge "${_bwrap_ver}" "0.6.3" && HAS_BIND_OVER_RO=1 } # Detect kernel-level security features that affect bwrap argument choice. @@ -139,18 +148,12 @@ detect_bwrap_capabilities() { # (terminal resize) is delivered correctly from tmux and other # multiplexers to the sandboxed process. detect_kernel_capabilities() { - local _kver _kmajor _kminor + local _kver _kver="$(uname -r)" _kver="${_kver%%-*}" # strip suffix e.g. "5.14.0-427.el9" -> "5.14.0" - IFS='.' read -r _kmajor _kminor _ <<< "${_kver}" - _kmajor="${_kmajor:-0}" - _kminor="${_kminor:-0}" KERNEL_HAS_TIOCSTI_CAP_GUARD=0 - if [[ "${_kmajor}" -gt 5 ]] || - { [[ "${_kmajor}" -eq 5 ]] && [[ "${_kminor}" -ge 14 ]]; }; then - KERNEL_HAS_TIOCSTI_CAP_GUARD=1 - fi + _version_ge "${_kver}" "5.14" && KERNEL_HAS_TIOCSTI_CAP_GUARD=1 } detect_shell() { @@ -163,6 +166,50 @@ detect_shell() { # Keys are canonical paths; value is always 1. # Pre-populated with unconditional mounts in build_dynamic_tool_mounts. +# _get_npm_prefix +# Echo the npm global prefix (`npm prefix -g`), caching the result so the +# subprocess is forked at most once per run even when several symlinked +# tools are resolved. Echoes the empty string if npm is not installed or +# the lookup fails. +_get_npm_prefix() { + if [[ "${_NPM_PREFIX_RESOLVED}" -eq 0 ]]; then + _NPM_PREFIX="$(npm prefix -g 2> /dev/null || true)" + _NPM_PREFIX_RESOLVED=1 + fi + printf '%s' "${_NPM_PREFIX}" +} + +# _emit_home_intermediate_dirs DIR [MODE] +# Emit `--dir` BWRAP_ARGS for the ancestor components of DIR that live +# under $HOME, so the eventual bind mount point exists inside the tmpfs +# $HOME (which starts out with no subdirectories). No-op when DIR is not +# under $HOME. +# +# MODE: +# "ancestors" (default) — emit --dir for every component strictly +# BETWEEN $HOME and DIR; DIR itself is left out because it +# will be the bind mount point. +# "inclusive" — also emit --dir for DIR itself (use when DIR is a parent +# directory that must exist, e.g. the directory holding a +# file bind, or a read-write directory bind point). +_emit_home_intermediate_dirs() { + local dir="$1" mode="${2:-ancestors}" + [[ "${dir}" == "${_HOME}"/* ]] || return 0 + local _rel="${dir#"${_HOME}"/}" + local _accum="${_HOME}" + local _parts _i _last + IFS='/' read -ra _parts <<< "${_rel}" + if [[ "${mode}" == "inclusive" ]]; then + _last=${#_parts[@]} + else + _last=$((${#_parts[@]} - 1)) + fi + for ((_i = 0; _i < _last; _i++)); do + _accum="${_accum}/${_parts[$_i]}" + BWRAP_ARGS+=(--dir "${_accum}") + done +} + # _mount_ro_dir_if_needed DIR # Bind-mount DIR read-only into the sandbox, unless it (or a parent # tree) is already registered in _MOUNTED_PREFIXES. If the bind is @@ -176,12 +223,15 @@ _mount_ro_dir_if_needed() { local dir="$1" [[ -d "${dir}" ]] || return 0 - # Check whether dir is already covered by a registered prefix. + # Check whether dir is already covered by a registered prefix. The walk + # tests every ancestor including "/" itself so a (theoretical) registered + # "/" prefix would de-duplicate correctly. local check="${dir}" - while [[ "${check}" != "/" ]]; do + while :; do if [[ -n "${_MOUNTED_PREFIXES["${check}"]:-}" ]]; then return 0 # already covered fi + [[ "${check}" == "/" ]] && break check="${check%/*}" [[ -z "${check}" ]] && check="/" done @@ -192,17 +242,9 @@ _mount_ro_dir_if_needed() { _check_path_safe "${dir}" "dynamic mount" # Create intermediate --dir entries for paths under $HOME (the - # tmpfs $HOME has no subdirectories yet). - if [[ "${dir}" == "${_HOME}"/* ]]; then - local _rel="${dir#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts _i - IFS='/' read -ra _parts <<< "${_rel}" - for ((_i = 0; _i < ${#_parts[@]} - 1; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done - fi + # tmpfs $HOME has no subdirectories yet). DIR is the bind mount point, + # so only its ancestors are pre-created. + _emit_home_intermediate_dirs "${dir}" BWRAP_ARGS+=(--ro-bind "${dir}" "${dir}") _MOUNTED_PREFIXES["${dir}"]=1 @@ -230,7 +272,7 @@ resolve_and_mount_tool() { if [[ -L "${bin_path}" ]]; then real_path="$(readlink -f "${bin_path}")" - npm_prefix="$(npm prefix -g 2> /dev/null || true)" + npm_prefix="$(_get_npm_prefix)" if [[ -n "${npm_prefix}" ]] && [[ "${real_path}" == "${npm_prefix}"/* ]]; then # npm-installed: mount the whole prefix so lib/node_modules is reachable. _mount_npm_global_prefix "${npm_prefix}" @@ -239,6 +281,8 @@ resolve_and_mount_tool() { _mount_ro_dir_if_needed "${cmd_dir}" _mount_ro_dir_if_needed "$(dirname "${real_path}")" else + # Non-symlink binary: mount its directory. _mount_ro_dir_if_needed + # de-duplicates internally, so no prior prefix check is needed here. _mount_ro_dir_if_needed "${cmd_dir}" fi } @@ -253,7 +297,7 @@ resolve_and_mount_tool() { _mount_npm_global_prefix() { local npm_prefix="${1:-}" if [[ -z "${npm_prefix}" ]]; then - npm_prefix="$(npm prefix -g 2> /dev/null || true)" + npm_prefix="$(_get_npm_prefix)" fi [[ -n "${npm_prefix}" ]] && [[ -d "${npm_prefix}" ]] || return 0 @@ -340,11 +384,9 @@ pass_through_if_set() { # which never matches any real path. _is_prefix_of() { local a="$1" b="$2" - if [[ "${a}" == "/" ]]; then - # Every absolute path is under /. - return 0 - fi - [[ "${b}" == "${a}" ]] || [[ "${b}" == "${a}/"* ]] + # a="/" matches every absolute path; otherwise b must equal a or sit + # directly beneath it (a/...). + [[ "${a}" == "/" || "${b}" == "${a}" || "${b}" == "${a}/"* ]] } # _check_path_safe CANON CONTEXT @@ -386,6 +428,11 @@ _is_prefix_of() { # default path ~/.local/share/uv/credentials # (confirmed via `uv auth dir`) # +# Password managers +# ~/.password-store pass/gopass GPG-encrypted secret store +# ~/.config/1Password 1Password CLI session and config +# ~/.config/op 1Password CLI (alternate config path) +# # GitHub CLI # ~/.config/gh/ GitHub CLI auth tokens (hosts.yml, etc.) # Note: bwcopilot intentionally mounts hosts.yml @@ -434,6 +481,10 @@ _check_path_safe() { "${_HOME}/.yarnrc.yml" "${_HOME}/.yarn" "${XDG_DATA_HOME}/uv/credentials" + # Password managers + "${_HOME}/.password-store" # pass / gopass encrypted secret store + "${_HOME}/.config/1Password" # 1Password CLI session/config + "${_HOME}/.config/op" # 1Password CLI (alternate path) # GitHub CLI tokens "${_HOME}/.config/gh" # System @@ -533,7 +584,7 @@ validate_extra_path() { # _MOUNTED_PREFIXES) and AFTER build_home_tmpfs (which creates the # $HOME tmpfs that intermediate --dir entries populate). build_extra_path_mounts() { - local _raw_path _canon + local _raw_path _canon _target # ── Read-only extra paths ──────────────────────────────────── for _raw_path in "${EXTRA_RO_PATHS[@]+"${EXTRA_RO_PATHS[@]}"}"; do @@ -543,19 +594,10 @@ build_extra_path_mounts() { # Directories: go through the dedup helper. _mount_ro_dir_if_needed "${_canon}" else - # Files: --ro-bind directly. Create intermediate --dir entries - # if the file lives under $HOME (the tmpfs has no subdirs yet). - if [[ "${_canon}" == "${_HOME}"/* ]]; then - local _file_parent="${_canon%/*}" - local _rel="${_file_parent#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts _i - IFS='/' read -ra _parts <<< "${_rel}" - for ((_i = 0; _i < ${#_parts[@]}; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done - fi + # Files: --ro-bind directly. Pre-create the file's parent + # directory chain if it lives under $HOME (the tmpfs has no + # subdirs yet); the file itself is the bind mount point. + _emit_home_intermediate_dirs "${_canon%/*}" inclusive BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") fi done @@ -564,23 +606,16 @@ build_extra_path_mounts() { for _raw_path in "${EXTRA_RW_PATHS[@]+"${EXTRA_RW_PATHS[@]}"}"; do _canon="$(validate_extra_path "${_raw_path}" "rw")" - # Create intermediate --dir entries if the path is under $HOME. - if [[ "${_canon}" == "${_HOME}"/* ]]; then - local _rel _target - if [[ -d "${_canon}" ]]; then - _target="${_canon}" - else - _target="${_canon%/*}" - fi - _rel="${_target#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts _i - IFS='/' read -ra _parts <<< "${_rel}" - for ((_i = 0; _i < ${#_parts[@]}; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done + # Pre-create the intermediate --dir chain when the path is under + # $HOME. For a directory the chain includes the target itself (the + # subsequent --bind lands on top of that --dir); for a file the + # chain stops at the parent directory. + if [[ -d "${_canon}" ]]; then + _target="${_canon}" + else + _target="${_canon%/*}" fi + _emit_home_intermediate_dirs "${_target}" inclusive BWRAP_ARGS+=(--bind "${_canon}" "${_canon}") done @@ -896,21 +931,10 @@ build_workdir_mount() { # already canonical paths, so no readlink -f step is needed here. _check_path_safe "${_BIND_DIR}" "working directory" - # If the bind dir is under HOME, create intermediate directories - # in the tmpfs so the bind mount point is reachable. - if [[ "${_BIND_DIR}" == "${_HOME}"/* ]]; then - local _rel="${_BIND_DIR#"${_HOME}"/}" - local _accum="${_HOME}" - local _parts - IFS='/' read -ra _parts <<< "${_rel}" - # Create all intermediate dirs except the final component (which - # will be the bind mount point itself). - local _i - for ((_i = 0; _i < ${#_parts[@]} - 1; _i++)); do - _accum="${_accum}/${_parts[$_i]}" - BWRAP_ARGS+=(--dir "${_accum}") - done - fi + # If the bind dir is under HOME, create intermediate directories in the + # tmpfs so the bind mount point is reachable. _BIND_DIR is itself the + # bind mount point, so only its ancestors are pre-created. + _emit_home_intermediate_dirs "${_BIND_DIR}" BWRAP_ARGS+=( --bind "${_BIND_DIR}" "${_BIND_DIR}" ) @@ -1057,10 +1081,22 @@ build_env_vars() { # We use compgen -e to list all exported vars and unset each one. # Note: we already exec with env -i, but --unsetenv ensures any # vars inherited through other means are also cleared. + # + # Read line-by-line (not `for x in $(...)`) so names are not + # word-split on IFS, and skip anything that is not a portable + # shell identifier — bash exports function definitions under names + # like `BASH_FUNC_module%%`, which would become invalid --unsetenv + # arguments. + # + # On HPC login nodes the exported-var count can reach the hundreds; + # each becomes a separate --unsetenv pair, so this path can produce + # a long bwrap command line. That is unavoidable here and harmless + # (well under ARG_MAX); the modern --clearenv path above avoids it. local _envvar - for _envvar in $(compgen -e); do + while IFS= read -r _envvar; do + [[ "${_envvar}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue BWRAP_ARGS+=(--unsetenv "${_envvar}") - done + done < <(compgen -e) fi # Always set @@ -1153,6 +1189,33 @@ resolve_env_bin() { exit 1 } +# _bwrap_flag_arity FLAG +# Echo the number of value arguments that bwrap flag FLAG consumes (0, 1, +# or 2). Used by print_dry_run to group flags with their values WITHOUT +# guessing from the value's leading characters — a path value that happens +# to start with "--" (e.g. a user-supplied --ro-path) must not be mistaken +# for a new flag. Only the flags this library actually emits are listed; +# the default of 1 is a safe fallback for any single-value flag. +_bwrap_flag_arity() { + case "$1" in + --unshare-* | --share-net | --die-with-parent | --new-session | \ + --clearenv | --as-pid-1) + echo 0 + ;; + --ro-bind | --ro-bind-try | --bind | --bind-try | --dev-bind | \ + --dev-bind-try | --symlink | --setenv | \ + --file | --bind-data | --ro-bind-data) + echo 2 + ;; + --dir | --tmpfs | --proc | --dev | --unsetenv | --chdir | --hostname) + echo 1 + ;; + *) + echo 1 + ;; + esac +} + print_dry_run() { local _BWRAP_BIN _ENV_BIN _BWRAP_BIN="$(command -v bwrap)" @@ -1169,24 +1232,20 @@ print_dry_run() { printf " LC_CTYPE=%q \\\\\n" "${LC_CTYPE:-C.UTF-8}" printf " %s \\\\\n" "${_BWRAP_BIN}" - # Print bwrap arguments one per line, quoting values with spaces - local i=0 arg + # Print bwrap arguments one flag-plus-values per line. Each flag's + # value count comes from _bwrap_flag_arity, so a value beginning with + # "--" is grouped with its flag instead of being misread as a new flag. + local i=0 arg _arity _j while [[ $i -lt ${#BWRAP_ARGS[@]} ]]; do arg="${BWRAP_ARGS[$i]}" - if [[ "${arg}" == --* ]]; then - # It's a flag; figure out how many values follow - printf " %s" "${arg}" + _arity="$(_bwrap_flag_arity "${arg}")" + printf " %s" "${arg}" + i=$((i + 1)) + for ((_j = 0; _j < _arity && i < ${#BWRAP_ARGS[@]}; _j++)); do + printf " '%s'" "${BWRAP_ARGS[$i]}" i=$((i + 1)) - # Print subsequent non-flag arguments on the same line - while [[ $i -lt ${#BWRAP_ARGS[@]} ]] && [[ "${BWRAP_ARGS[$i]}" != --* ]]; do - printf " '%s'" "${BWRAP_ARGS[$i]}" - i=$((i + 1)) - done - printf " \\\\\n" - else - printf " '%s' \\\\\n" "${arg}" - i=$((i + 1)) - fi + done + printf " \\\\\n" done printf " --" local _cmd_part diff --git a/lib/gpg-passwd.sh b/lib/gpg-passwd.sh index 1e14b20..1c2e796 100644 --- a/lib/gpg-passwd.sh +++ b/lib/gpg-passwd.sh @@ -57,7 +57,8 @@ decrypt_env_file() { local var value for var in "${required_vars[@]}"; do - eval "value=\${$var:-}" + local -n _ref="$var" + value="${_ref:-}" if [[ -z "$value" ]]; then printf 'decrypt_env_file: required variable %s is still empty after decrypt.\n' "$var" >&2 return 2