K-114: input validation in build.func + mini CI (nexus-hub card)

- validation helpers: sanitize_input trims CR/edge whitespace only;
  embedded control/non-ASCII bytes FAIL validation and re-prompt with a
  hint (2026-06-11 incident: invisible byte in a pasted VLAN tag broke
  pct create mid-run) - never silently stripped
- prompt_lxc_config: every prompt validated (uint for CTID/disk/cores/
  RAM, VLAN 1-4094, hostname/token formats, IP/CIDR/gateway, DNS list);
  env-provided values are sanitized + validated too (abort, no re-prompt
  loop in non-interactive use); helpers reusable for app prompts
- tests/test_validation.sh: 34 cases incl. the 2<0x80>0 repro, re-prompt
  simulation, BASH_REMATCH clobbering regression (is_cidr), env dry-run
  of prompt_lxc_config without PVE/TTY
- tests/check_ct_source.sh: every ct/*.sh must source build.func (bug
  shipped twice); negative proof via prepared fixture in the test suite
- .gitea/workflows/ci.yml: bash -n over all scripts, source-check,
  validation tests, shellcheck if present (documented skip otherwise)
- README: contributions via PR with cross-review (binding)
This commit is contained in:
2026-06-12 03:14:32 +02:00
parent cad741a97c
commit 80e2ec04ff
5 changed files with 347 additions and 22 deletions
+134 -22
View File
@@ -97,55 +97,166 @@ apply_network_profile() {
return 0
}
# ── input validation (K-114) ─────────────────────────────────────────────────
# Real incident 2026-06-11: a pasted VLAN tag carried an invisible non-UTF-8
# byte and broke `pct create` deep in the run. Policy: trim CR/edge whitespace,
# but NEVER silently strip junk inside a value — embedded control/non-ASCII
# bytes fail validation and trigger a visible re-prompt.
# Trim \r and leading/trailing whitespace (edges only).
sanitize_input() {
local s="${1-}"
s="${s//$'\r'/}"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
# True if the value contains only printable ASCII (no control/non-ASCII
# bytes). Byte-exact via tr: delete all printable ASCII — anything left
# over is junk.
is_clean_ascii() {
local leftover
leftover="$(printf '%s' "$1" | LC_ALL=C tr -d '\40-\176')"
[[ -z "$leftover" ]]
}
is_uint() { is_clean_ascii "$1" && [[ "$1" =~ ^[0-9]+$ ]]; }
is_vlan_tag() { is_uint "$1" && (( $1 >= 1 && $1 <= 4094 )); }
is_token() { is_clean_ascii "$1" && [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; }
is_hostname() { is_clean_ascii "$1" && [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; }
is_ipv4() {
is_clean_ascii "$1" && [[ "$1" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
local o
for o in "${BASH_REMATCH[@]:1:4}"; do (( o <= 255 )) || return 1; done
return 0
}
is_cidr() {
[[ "$1" =~ ^([0-9.]+)/([0-9]{1,2})$ ]] || return 1
# BASH_REMATCH retten — is_ipv4 nutzt selbst =~ und überschreibt es.
local _ip="${BASH_REMATCH[1]}" _prefix="${BASH_REMATCH[2]}"
is_ipv4 "$_ip" && (( _prefix >= 1 && _prefix <= 32 ))
}
is_ipcfg() { [[ "$1" == "dhcp" ]] || is_cidr "$1"; }
# Space/comma-separated list of IPv4s (DNS prompt).
is_ipv4_list() {
local item
for item in ${1//,/ }; do is_ipv4 "$item" || return 1; done
[[ -n "$1" ]]
}
# prompt_validated VARNAME PROMPT VALIDATOR [DEFAULT] [allow_empty]
# Reads into VARNAME (nameref, no subshell), sanitizes, applies the default on
# empty input and re-prompts until the validator passes. allow_empty=yes lets
# an empty value through (e.g. "no VLAN").
prompt_validated() {
local -n _pv_ref="$1"
local _pv_prompt="$2" _pv_validator="$3" _pv_default="${4-}" _pv_allow_empty="${5:-no}"
local _pv_value
while true; do
read -rp "$_pv_prompt" _pv_value
_pv_value="$(sanitize_input "$_pv_value")"
if [[ -z "$_pv_value" && -n "$_pv_default" ]]; then
_pv_value="$_pv_default"
fi
if [[ -z "$_pv_value" ]]; then
if [[ "$_pv_allow_empty" == "yes" ]]; then _pv_ref=""; return 0; fi
msg_warn "A value is required."
continue
fi
if "$_pv_validator" "$_pv_value"; then
_pv_ref="$_pv_value"
return 0
fi
if ! is_clean_ascii "$_pv_value"; then
msg_warn "Input contains invisible/non-ASCII characters — please re-type (do not paste)."
else
msg_warn "Invalid value: '$_pv_value' — please retry."
fi
done
}
# Validate an env-provided value (non-interactive: abort instead of re-prompt).
require_valid() {
local -n _rv_ref="$1"
local _rv_validator="$2" _rv_label="$3"
_rv_ref="$(sanitize_input "$_rv_ref")"
"$_rv_validator" "$_rv_ref" || { msg_err "$_rv_label invalid: '$_rv_ref'"; exit 1; }
}
# ── prompts ──────────────────────────────────────────────────────────────────
# Each prompt is skipped if the corresponding variable is already set in env.
# Each prompt is skipped if the corresponding variable is already set in env
# (env values are still validated — abort on invalid, no silent use).
# VLAN_TAG and NAMESERVER use ${VAR+x} so that explicitly setting them to ""
# via env skips the prompt (= "no VLAN" / "inherit DNS from host").
prompt_lxc_config() {
if [[ -z "${CTID:-}" ]]; then
read -rp "Container ID [auto]: " CTID
CTID="$(sanitize_input "$CTID")"
[[ -z "${CTID:-}" ]] && CTID=$(pvesh get /cluster/nextid)
fi
require_valid CTID is_uint "Container ID"
echo " → CTID: $CTID"
if [[ -z "${CT_HOSTNAME:-}" ]]; then
read -rp "Hostname [$DEFAULT_HOSTNAME]: " CT_HOSTNAME
CT_HOSTNAME="${CT_HOSTNAME:-$DEFAULT_HOSTNAME}"
prompt_validated CT_HOSTNAME "Hostname [$DEFAULT_HOSTNAME]: " is_hostname "$DEFAULT_HOSTNAME"
else
require_valid CT_HOSTNAME is_hostname "Hostname"
fi
if [[ -z "${DISK_SIZE:-}" ]]; then
read -rp "Disk size in GB [$DEFAULT_DISK]: " DISK_SIZE
DISK_SIZE="${DISK_SIZE:-$DEFAULT_DISK}"
prompt_validated DISK_SIZE "Disk size in GB [$DEFAULT_DISK]: " is_uint "$DEFAULT_DISK"
else
require_valid DISK_SIZE is_uint "Disk size"
fi
if [[ -z "${CORES:-}" ]]; then
read -rp "vCPU cores [$DEFAULT_CORES]: " CORES
CORES="${CORES:-$DEFAULT_CORES}"
prompt_validated CORES "vCPU cores [$DEFAULT_CORES]: " is_uint "$DEFAULT_CORES"
else
require_valid CORES is_uint "vCPU cores"
fi
if [[ -z "${RAM:-}" ]]; then
read -rp "RAM in MB [$DEFAULT_RAM]: " RAM
RAM="${RAM:-$DEFAULT_RAM}"
prompt_validated RAM "RAM in MB [$DEFAULT_RAM]: " is_uint "$DEFAULT_RAM"
else
require_valid RAM is_uint "RAM"
fi
if [[ -z "${BRIDGE:-}" ]]; then
read -rp "Bridge [$DEFAULT_BRIDGE]: " BRIDGE
BRIDGE="${BRIDGE:-$DEFAULT_BRIDGE}"
prompt_validated BRIDGE "Bridge [$DEFAULT_BRIDGE]: " is_token "$DEFAULT_BRIDGE"
else
require_valid BRIDGE is_token "Bridge"
fi
if [[ -z "${VLAN_TAG+x}" ]]; then
read -rp "VLAN tag (empty for none): " VLAN_TAG
# The 2026-06-11 incident prompt: junk bytes re-prompt, empty = no VLAN.
prompt_validated VLAN_TAG "VLAN tag (empty for none): " is_vlan_tag "" yes
elif [[ -n "${VLAN_TAG:-}" ]]; then
require_valid VLAN_TAG is_vlan_tag "VLAN tag"
fi
if [[ -z "${TEMPLATE_STORAGE:-}" ]]; then
read -rp "Template storage [$DEFAULT_TEMPLATE_STORAGE]: " TEMPLATE_STORAGE
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-$DEFAULT_TEMPLATE_STORAGE}"
prompt_validated TEMPLATE_STORAGE \
"Template storage [$DEFAULT_TEMPLATE_STORAGE]: " is_token "$DEFAULT_TEMPLATE_STORAGE"
else
require_valid TEMPLATE_STORAGE is_token "Template storage"
fi
if [[ -z "${ROOTFS_STORAGE:-}" ]]; then
read -rp "Rootfs storage [$DEFAULT_ROOTFS_STORAGE]: " ROOTFS_STORAGE
ROOTFS_STORAGE="${ROOTFS_STORAGE:-$DEFAULT_ROOTFS_STORAGE}"
prompt_validated ROOTFS_STORAGE \
"Rootfs storage [$DEFAULT_ROOTFS_STORAGE]: " is_token "$DEFAULT_ROOTFS_STORAGE"
else
require_valid ROOTFS_STORAGE is_token "Rootfs storage"
fi
if [[ -z "${IPCFG:-}" ]]; then
read -rp "Network: IP/CIDR or 'dhcp' [dhcp]: " IPCFG
IPCFG="${IPCFG:-dhcp}"
prompt_validated IPCFG "Network: IP/CIDR or 'dhcp' [dhcp]: " is_ipcfg "dhcp"
else
require_valid IPCFG is_ipcfg "Network (IP/CIDR or dhcp)"
fi
GATEWAY="${GATEWAY:-}"
if [[ "$IPCFG" != "dhcp" && -z "$GATEWAY" ]]; then
read -rp "Gateway: " GATEWAY
if [[ "$IPCFG" != "dhcp" ]]; then
if [[ -z "$GATEWAY" ]]; then
prompt_validated GATEWAY "Gateway: " is_ipv4
else
require_valid GATEWAY is_ipv4 "Gateway"
fi
fi
# DNS is driven by the VLAN tag via the network profile (lib/networks.conf),
# so the right resolvers get set even with DHCP. Precedence:
@@ -161,8 +272,9 @@ prompt_lxc_config() {
msg_info "DNS for VLAN ${VLAN_TAG:-none} (${PROFILE_SUBNET:-?}): $NAMESERVER"
elif [[ "$IPCFG" != "dhcp" ]]; then
local default_ns="${DEFAULT_NAMESERVER:-$GATEWAY}"
read -rp "DNS server [$default_ns] (empty = inherit from PVE host): " NAMESERVER
NAMESERVER="${NAMESERVER:-$default_ns}"
prompt_validated NAMESERVER \
"DNS server [$default_ns] (empty = inherit from PVE host): " \
is_ipv4_list "$default_ns" yes
else
msg_warn "No network profile for VLAN ${VLAN_TAG:-none}; DHCP DNS will be inherited."
fi