diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ef06a44 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4bee82..e55445f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,19 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: shellcheck: + name: Shell checks runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Install shellcheck run: | @@ -29,8 +36,18 @@ jobs: fi shellcheck -x "${files[@]}" - - name: Check shell formatting (shfmt) + - name: Download, verify, and run shfmt + env: + SHFMT_VERSION: 3.13.1 + SHFMT_SHA256: fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1 run: | - curl -sS https://webinstall.dev/shfmt | bash - export PATH="$HOME/.local/bin:$PATH" - shfmt -d proxmox-manager.sh + set -euo pipefail + shfmt_path="${RUNNER_TEMP}/shfmt" + curl --fail --location --silent --show-error \ + --proto '=https' --tlsv1.2 \ + --retry 3 \ + "https://github.com/mvdan/sh/releases/download/v${SHFMT_VERSION}/shfmt_v${SHFMT_VERSION}_linux_amd64" \ + --output "${shfmt_path}" + printf '%s %s\n' "${SHFMT_SHA256}" "${shfmt_path}" | sha256sum --check --strict + chmod 0755 "${shfmt_path}" + "${shfmt_path}" -d proxmox-manager.sh diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 752d718..487220f 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -6,30 +6,47 @@ on: permissions: contents: read +concurrency: + group: gitleaks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: scan: + name: Secret scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 + persist-credentials: false - - name: Install gitleaks CLI + - name: Download and verify gitleaks CLI + env: + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb run: | - GITLEAKS_VERSION=8.21.2 - curl -sSfL \ + set -euo pipefail + archive="${RUNNER_TEMP}/gitleaks.tar.gz" + install_dir="${RUNNER_TEMP}/gitleaks-bin" + curl --fail --location --silent --show-error \ + --proto '=https' --tlsv1.2 \ + --retry 3 \ "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | sudo tar -xz -C /usr/local/bin gitleaks + --output "${archive}" + printf '%s %s\n' "${GITLEAKS_SHA256}" "${archive}" | sha256sum --check --strict + mkdir -p "${install_dir}" + tar -xzf "${archive}" -C "${install_dir}" gitleaks + printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}" - - name: Run gitleaks (workspace scan) + - name: Run gitleaks (history scan) run: | - gitleaks detect --redact --source . \ + gitleaks git --redact --log-opts="--all" \ --config .gitleaks.toml \ - --report-format=sarif --report-path=results.sarif --exit-code=1 + --report-format=sarif --report-path=results.sarif --exit-code=1 . - name: Upload SARIF report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: gitleaks-results path: results.sarif diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0687a25..0bc2318 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,18 +5,29 @@ on: tags: - "v*" -permissions: - contents: write +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: release: + name: Publish release + permissions: + contents: write # Required to create a GitHub release and upload its asset. runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: proxmox-manager.sh - generate_release_notes: false + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" proxmox-manager.sh \ + --verify-tag \ + --title "$GITHUB_REF_NAME" \ + --notes "" diff --git a/.gitleaks.toml b/.gitleaks.toml index 4538910..efec584 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -2,9 +2,3 @@ title = "Gitleaks config for proxmox-manager" [extend] useDefault = true - -[allowlist] -description = "Ignore example/placeholder values in documentation" -paths = [ - "README.md", -] diff --git a/CHANGELOG.md b/CHANGELOG.md index d9009df..606ed7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Security +- Configuration files are now parsed as allowlisted data instead of being executed with `source`. +- Log files must be private, owner-controlled regular files; unsafe paths and symlinks are rejected. +- The system-wide `pman` command is installed as an atomic root-owned copy instead of a symlink to a potentially user-writable checkout. +- GitHub Actions are commit-SHA pinned, remote tool downloads are checksum-verified, workflow permissions are minimized, and checkout credentials are not persisted. +- User-controlled status and message text is no longer interpreted as `printf %b` escape sequences. + ### Fixed - VM IP lookup now accepts the native QEMU Guest Agent list payload from `qm agent ... network-get-interfaces` as well as the earlier wrapped shape, preventing parser errors on hosts that return a bare JSON array. diff --git a/README.md b/README.md index 1b7ad90..a37f6f6 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Check out how others are using `proxmox-manager`: | 📦 | **Snapshot Management** | List, create, rollback, delete — with name validation and snapshot preview before destructive actions | | 🖱️ | **SPICE Integration** | Enable SPICE for VMs and retrieve `.vv` connection files. Auto-launches `virt-viewer` when installed | | 🤖 | **Automation-Ready** | `--json` output, `--filter` by status, `--name` ERE filter, `--force` mode, structured logging via `LOG_FILE` | -| ⚙️ | **Config File** | Persistent defaults via `/etc/pmanrc` or `~/.pmanrc` — CLI flags always win | +| ⚙️ | **Config File** | Persistent allowlisted defaults via `/etc/pmanrc` or `~/.pmanrc` — CLI flags always win | --- @@ -125,7 +125,7 @@ chmod +x proxmox-manager.sh ./install_dependencies.sh ``` -Installs a symlink to `/usr/local/bin/pman` so you can call `pman` from anywhere on the host. +Installs an atomic, root-owned copy at `/usr/local/bin/pman` so later edits to the checkout cannot change the privileged executable. --- @@ -198,9 +198,11 @@ CLI flags are applied last and always win over config file values. # ~/.pmanrc — example STOP_TIMEOUT=120 # stop timeout in seconds (default: 60) LOG_FILE="/var/log/proxmox-manager.log" # structured log file (empty = disabled) -PROXMOX_MANAGER_SPICE_ADDR="192.168.1.10" # SPICE bind address (default: 127.0.0.1) +PROXMOX_MANAGER_SPICE_ADDR="spice.example.invalid" # SPICE bind address ``` +Configuration files are parsed as data, not executed as shell code. Only the three settings shown above are accepted. When enabled, `LOG_FILE` must be an absolute, non-symlinked regular file owned by the current user with mode `0600`; its parent directory must also be owner-controlled and not group/world-writable. + ### Shell Completions ```bash diff --git a/install_dependencies.sh b/install_dependencies.sh index e7a6445..befdb2e 100755 --- a/install_dependencies.sh +++ b/install_dependencies.sh @@ -71,10 +71,11 @@ else fi # --------------------------------------------------------------------------- -# Install pman — global symlink in /usr/local/bin +# Install pman — root-owned copy in /usr/local/bin # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" PMAN_SRC="${SCRIPT_DIR}/proxmox-manager.sh" +PMAN_DIR="/usr/local/bin" PMAN_DST="/usr/local/bin/pman" if [[ ! -f "$PMAN_SRC" ]]; then @@ -82,13 +83,33 @@ if [[ ! -f "$PMAN_SRC" ]]; then exit 1 fi -chmod +x "$PMAN_SRC" +if [[ ! -d "$PMAN_DIR" || -L "$PMAN_DIR" ]]; then + _err "${PMAN_DIR} must be an existing regular directory." + exit 1 +fi -if [[ -L "$PMAN_DST" && "$(readlink -f "$PMAN_DST")" == "$(readlink -f "$PMAN_SRC")" ]]; then - _log "pman already installed: ${PMAN_DST}" -else - ln -sf "$PMAN_SRC" "$PMAN_DST" - _ok "Installed: pman -> ${PMAN_SRC}" +PMAN_DIR_OWNER="$(stat -Lc '%u' -- "$PMAN_DIR" 2>/dev/null || printf 'invalid')" +PMAN_DIR_MODE="$(stat -Lc '%a' -- "$PMAN_DIR" 2>/dev/null || printf 'invalid')" +if [[ "$PMAN_DIR_OWNER" != "0" || ! "$PMAN_DIR_MODE" =~ ^[0-7]{3,4}$ ]] || ((8#$PMAN_DIR_MODE & 8#022)); then + _err "${PMAN_DIR} must be root-owned and not group/world-writable." + exit 1 +fi + +PMAN_TMP="$(mktemp -p "$PMAN_DIR" '.pman.XXXXXX')" || { + _err "Could not create a temporary install file in ${PMAN_DIR}." + exit 1 +} + +if ! install -o root -g root -m 0755 "$PMAN_SRC" "$PMAN_TMP" || ! cmp -s "$PMAN_SRC" "$PMAN_TMP"; then + rm -f -- "$PMAN_TMP" + _err "Failed to stage a verified pman executable." + exit 1 +fi +if ! mv -fT -- "$PMAN_TMP" "$PMAN_DST"; then + rm -f -- "$PMAN_TMP" + _err "Failed to install ${PMAN_DST}." + exit 1 fi +_ok "Installed root-owned executable: ${PMAN_DST}" _ok "Done. All optional dependencies are available. Run 'pman' to start." diff --git a/proxmox-manager.sh b/proxmox-manager.sh index c181f89..2039115 100755 --- a/proxmox-manager.sh +++ b/proxmox-manager.sh @@ -24,9 +24,10 @@ JSON_FLAG=0 LOG_FILE="${LOG_FILE:-}" # Set LOG_FILE=/path/to/file to enable file logging FILTER_STATUS="" # Filter output by status: running|stopped|paused (empty = no filter) STOP_TIMEOUT="${STOP_TIMEOUT:-60}" # Timeout in seconds for stop operations; env-overridable -FORCE_MODE=0 # Set to 1 via --force to skip all confirm() prompts -FILTER_NAME="" # ERE substring-match against VM/CT name (empty = no filter) -declare -A _type_cache=() # ID→type cache populated by main_menu; used by type_of_id() +PROXMOX_MANAGER_SPICE_ADDR="${PROXMOX_MANAGER_SPICE_ADDR:-}" +FORCE_MODE=0 # Set to 1 via --force to skip all confirm() prompts +FILTER_NAME="" # ERE substring-match against VM/CT name (empty = no filter) +declare -A _type_cache=() # ID→type cache populated by main_menu; used by type_of_id() # ============================================================================= # COLORS (active only on a real TTY, or when NO_COLOR is unset) @@ -115,19 +116,19 @@ trap 'printf "\n%s\n" "Exiting."; exit 0' INT TERM have() { command -v "$1" >/dev/null 2>&1; } err() { - printf '%b\n' " ${RED_BRIGHT}✖ Error:${NC} $*" >&2 + printf ' %b✖ Error:%b %s\n' "$RED_BRIGHT" "$NC" "$*" >&2 log "ERROR" "$*" } ok() { - printf '%b\n' " ${GREEN_BRIGHT}✔ ${NC}$*" + printf ' %b✔ %b%s\n' "$GREEN_BRIGHT" "$NC" "$*" log "OK" "$*" } note() { - printf '%b\n' " ${CYAN_BRIGHT}→ ${NC}$*" + printf ' %b→ %b%s\n' "$CYAN_BRIGHT" "$NC" "$*" log "NOTE" "$*" } warn() { - printf '%b\n' " ${YELLOW_BRIGHT}⚠ Warning:${NC} $*" + printf ' %b⚠ Warning:%b %s\n' "$YELLOW_BRIGHT" "$NC" "$*" log "WARN" "$*" } @@ -172,6 +173,99 @@ trim() { printf '%s' "$v" } +# _config_warn MESSAGE — report config problems without writing to LOG_FILE. +# LOG_FILE is not trusted until _prepare_log_file() has validated it. +_config_warn() { + printf ' Warning: %s\n' "$*" >&2 +} + +# _load_config_file FILE — parse allowlisted KEY=VALUE settings as data, never shell code. +_load_config_file() { + local file="$1" raw line key value + [[ -f "$file" ]] || return 0 + + while IFS= read -r raw || [[ -n "$raw" ]]; do + line="$(trim "$raw")" + [[ -z "$line" || "$line" == \#* ]] && continue + + if [[ ! "$line" =~ ^([A-Z_][A-Z0-9_]*)[[:space:]]*=(.*)$ ]]; then + _config_warn "Ignoring invalid config line in $file." + continue + fi + key="${BASH_REMATCH[1]}" + value="$(trim "${BASH_REMATCH[2]}")" + + if [[ "$value" =~ ^\"([^\"]*)\"([[:space:]]*#.*)?$ ]]; then + value="${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^\'([^\']*)\'([[:space:]]*#.*)?$ ]]; then + value="${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^([^[:space:]#]*)([[:space:]]+#.*)?$ ]]; then + value="${BASH_REMATCH[1]}" + else + _config_warn "Ignoring malformed value for $key in $file." + continue + fi + + case "$key" in + STOP_TIMEOUT | LOG_FILE | PROXMOX_MANAGER_SPICE_ADDR) + printf -v "$key" '%s' "$value" + ;; + *) + _config_warn "Ignoring unsupported setting '$key' in $file." + ;; + esac + done <"$file" +} + +# _prepare_log_file — require a private regular file in a trusted directory. +_prepare_log_file() { + [[ -z "$LOG_FILE" ]] && return 0 + if [[ "$LOG_FILE" != /* ]]; then + _config_warn "LOG_FILE must be an absolute path; logging disabled." + LOG_FILE='' + return 1 + fi + + local parent owner mode + parent="$(dirname -- "$LOG_FILE")" + if [[ ! -d "$parent" ]]; then + _config_warn "LOG_FILE parent directory does not exist; logging disabled." + LOG_FILE='' + return 1 + fi + owner="$(stat -Lc '%u' -- "$parent" 2>/dev/null || printf 'invalid')" + mode="$(stat -Lc '%a' -- "$parent" 2>/dev/null || printf 'invalid')" + if [[ "$owner" != "$EUID" || ! "$mode" =~ ^[0-7]{3,4}$ ]] || ((8#$mode & 8#022)); then + _config_warn "LOG_FILE parent directory is not private and owner-controlled; logging disabled." + LOG_FILE='' + return 1 + fi + + if [[ -e "$LOG_FILE" || -L "$LOG_FILE" ]]; then + if [[ -L "$LOG_FILE" || ! -f "$LOG_FILE" ]]; then + _config_warn "LOG_FILE must be a regular file and not a symlink; logging disabled." + LOG_FILE='' + return 1 + fi + owner="$(stat -Lc '%u' -- "$LOG_FILE" 2>/dev/null || printf 'invalid')" + mode="$(stat -Lc '%a' -- "$LOG_FILE" 2>/dev/null || printf 'invalid')" + if [[ "$owner" != "$EUID" || ! "$mode" =~ ^[0-7]{3,4}$ ]] || ((8#$mode & 8#077)); then + _config_warn "LOG_FILE must be owned by the current user with mode 0600; logging disabled." + LOG_FILE='' + return 1 + fi + elif ! ( + umask 077 + set -o noclobber + : >"$LOG_FILE" + ) 2>/dev/null; then + _config_warn "LOG_FILE could not be created safely; logging disabled." + LOG_FILE='' + return 1 + fi + return 0 +} + # _repeat CHAR N — print CHAR repeated N times. _repeat() { local char="$1" n="$2" out='' @@ -621,10 +715,10 @@ header() { _status_color() { local st="$1" txt="$2" case "$st" in - running) printf '%b' "${GREEN_BRIGHT}${txt}${NC}" ;; - stopped) printf '%b' "${RED_BRIGHT}${txt}${NC}" ;; - paused) printf '%b' "${YELLOW_BRIGHT}${txt}${NC}" ;; - *) printf '%b' "${DIM}${txt}${NC}" ;; + running) printf '%b%s%b' "$GREEN_BRIGHT" "$txt" "$NC" ;; + stopped) printf '%b%s%b' "$RED_BRIGHT" "$txt" "$NC" ;; + paused) printf '%b%s%b' "$YELLOW_BRIGHT" "$txt" "$NC" ;; + *) printf '%b%s%b' "$DIM" "$txt" "$NC" ;; esac } @@ -632,10 +726,10 @@ _status_color() { _status_sym_color() { local st="$1" sym="$2" case "$st" in - running) printf '%b' "${GREEN_BRIGHT}${sym}${NC}" ;; - stopped) printf '%b' "${RED_BRIGHT}${sym}${NC}" ;; - paused) printf '%b' "${YELLOW_BRIGHT}${sym}${NC}" ;; - *) printf '%b' "${DIM}${sym}${NC}" ;; + running) printf '%b%s%b' "$GREEN_BRIGHT" "$sym" "$NC" ;; + stopped) printf '%b%s%b' "$RED_BRIGHT" "$sym" "$NC" ;; + paused) printf '%b%s%b' "$YELLOW_BRIGHT" "$sym" "$NC" ;; + *) printf '%b%s%b' "$DIM" "$sym" "$NC" ;; esac } @@ -777,11 +871,11 @@ print_table() { confirm() { local prompt="$1" if ((FORCE_MODE == 1)); then - printf '%b\n' " ${YELLOW_BRIGHT}[--force]${NC} Skipping confirmation: ${prompt}" + printf ' %b[--force]%b Skipping confirmation: %s\n' "$YELLOW_BRIGHT" "$NC" "$prompt" return 0 fi local ans - printf '%b' " ${YELLOW}${prompt} [y/N]:${NC} " + printf ' %b%s [y/N]:%b ' "$YELLOW" "$prompt" "$NC" read_line ans [[ "$ans" =~ ^[yY]$ ]] } @@ -1524,15 +1618,18 @@ ip_info() { main() { # Load config files — CLI flags set by parse_args below will override these values. - # shellcheck source=/dev/null - [[ -f /etc/pmanrc ]] && source /etc/pmanrc - # shellcheck source=/dev/null - [[ -f "${HOME}/.pmanrc" ]] && source "${HOME}/.pmanrc" - # Validate STOP_TIMEOUT after sourcing config (may have been set there). + _load_config_file /etc/pmanrc + _load_config_file "${HOME}/.pmanrc" + _prepare_log_file || true + # Validate settings after parsing config as data. if [[ ! "$STOP_TIMEOUT" =~ ^[0-9]+$ ]] || ((STOP_TIMEOUT < 1)); then err "STOP_TIMEOUT must be a positive integer (got '$STOP_TIMEOUT'). Check /etc/pmanrc or ~/.pmanrc." exit 1 fi + if [[ -n "$PROXMOX_MANAGER_SPICE_ADDR" && ! "$PROXMOX_MANAGER_SPICE_ADDR" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]]; then + err "PROXMOX_MANAGER_SPICE_ADDR contains unsupported characters." + exit 1 + fi parse_args "$@" if ((FORCE_MODE == 1)); then warn "--force active: all confirmation prompts will be skipped automatically." diff --git a/tests/run.sh b/tests/run.sh index 8096cc3..825a7cf 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -112,6 +112,72 @@ fi # shellcheck source=./proxmox-manager.sh disable=SC1091 source <(grep -v '^main ' "$SCRIPT") +# Config files must be parsed as data and never execute shell content. +config_file="$(mktemp)" +config_probe="$(mktemp)" +rm -f "$config_probe" +printf '%s\n' \ + 'STOP_TIMEOUT=90' \ + 'LOG_FILE="/var/log/proxmox manager.log"' \ + 'PROXMOX_MANAGER_SPICE_ADDR=spice.example.invalid' \ + "UNSUPPORTED=\$(touch $config_probe)" >"$config_file" +STOP_TIMEOUT=60 +LOG_FILE='' +PROXMOX_MANAGER_SPICE_ADDR='' +_load_config_file "$config_file" 2>/dev/null +if [[ "$STOP_TIMEOUT" == "90" && "$LOG_FILE" == "/var/log/proxmox manager.log" && "$PROXMOX_MANAGER_SPICE_ADDR" == "spice.example.invalid" && ! -e "$config_probe" ]]; then + _pass "config parser: allowlisted values parsed without code execution" +else + _fail "config parser: unsafe execution or incorrect parsing" +fi +# shellcheck disable=SC2016 # Intentional literal command substitution attack string. +printf 'LOG_FILE="$(touch %s)"\n' "$config_probe" >"$config_file" +LOG_FILE='' +_load_config_file "$config_file" 2>/dev/null +# shellcheck disable=SC2016 # Verify that the attack string remained literal data. +if [[ ! -e "$config_probe" && "$LOG_FILE" == *'$('* ]]; then + _pass "config parser: treats command substitution as literal data" +else + _fail "config parser: executed command substitution" +fi +rm -f "$config_file" "$config_probe" +STOP_TIMEOUT=60 +LOG_FILE='' +PROXMOX_MANAGER_SPICE_ADDR='' + +# Log files must be private regular files and symlinks must be rejected. +log_dir="$(mktemp -d)" +chmod 700 "$log_dir" +LOG_FILE="$log_dir/pman.log" +if _prepare_log_file && [[ -f "$LOG_FILE" && ! -L "$LOG_FILE" && "$(stat -c '%a' "$LOG_FILE")" == "600" ]]; then + _pass "log security: creates a private regular file" +else + _fail "log security: failed to create a private regular file" +fi +rm -f "$LOG_FILE" +log_target="$log_dir/target" +log_link="$log_dir/link" +: >"$log_target" +ln -s "$log_target" "$log_link" +LOG_FILE="$log_link" +if ! _prepare_log_file 2>/dev/null && [[ -z "$LOG_FILE" && ! -s "$log_target" ]]; then + _pass "log security: rejects symlink targets" +else + _fail "log security: accepted a symlink target" +fi +rm -f "$log_link" "$log_target" +rmdir "$log_dir" + +# User-controlled messages must not interpret backslash escapes. +LOG_FILE='' +literal_message='message\\cstill-visible' +message_out="$(err "$literal_message" 2>&1)" +if [[ "$message_out" == *"$literal_message"* ]]; then + _pass "output safety: preserves literal backslash escapes" +else + _fail "output safety: interpreted backslash escapes" +fi + _vmid_test() { local id="$1" expect_exit="$2" label="$3" actual_exit=0 validate_vmid "$id" >/dev/null 2>&1 && actual_exit=0 || actual_exit=$?