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:
+134
-22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user