Skip to content
Merged
8 changes: 8 additions & 0 deletions NEWS.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,14 @@ https://github.com/networkupstools/nut/milestone/13
aspect of that, it should now abort with an explanation (and not proceed
with insecure start-up like it could do before). [issue #3331, PR #3435]

- The `nut-driver-enumerator.sh` script (NDE) updates:
* The shell/awk based parser now decodes `ups.conf` values using NUT token
syntax, preserving quoted hashes and escaped characters and ignoring
trailing comments. For example, `port = "auto" # comment` now properly
selects USB media for `nutdrv_qx`. The normalized configuration output
retains quotes and backslashes; changed section checksums can cause a
one-time reconciliation after upgrading. [PR #3635]

- Recipes, CI and helper script updates not classified above:
* Introduced `AGENTS.md` for AI agents to help in a consistent manner with
minimal friction. [#3608]
Expand Down
8 changes: 8 additions & 0 deletions UPGRADING.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ The new `-A filename` option defaults to trying to use a `nutauth.conf` file
reading one ('none' as the legacy default). See the updated manual pages
for more details. [issues #3329, #3411]

- `nut-driver-enumerator` now preserves quotes and backslashes in normalized
configuration output and decodes values once using NUT token syntax.
Value queries omit trailing comments, take the first value token and
treat single quotes literally. This corrected parsing can change media
dependencies. Section checksums may change on upgrade and trigger the
usual reconciliation, including driver restarts; subsequent checks of
unchanged configuration remain stable. [PR #3635]

- The `powervar_cx_usb`, `tripplite_usb` drivers used a built-in limit on
reconnection attempts after which they exited ('60' and '10' respectively).
This was revised to follow the new common setting `reconnect_max_tries`,
Expand Down
13 changes: 11 additions & 2 deletions docs/man/nut-driver-enumerator.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,22 @@ NUT device names which each such unit wraps

*nut-driver-enumerator.sh --show-all-configs*::
Show the complete normalized list of device configuration blocks
(same as used later by the parser in the script to make decisions)
(same as used later by the parser in the script to make decisions).
Quotes and backslashes are retained until values are queried.
Physical lines joined by a backslash continuation or inside double
quotes are combined. Section checksums use this normalized text,
including a final newline.

*nut-driver-enumerator.sh --show-device-config DEV*::
Show configuration block of the specified NUT device

*nut-driver-enumerator.sh --show-device-config-value DEV KEY*::
Show single configuration key of the specified NUT device
Show the decoded value of a configuration key of the specified NUT device.
The first value token is used: double quotes group a token, backslash
escapes the next character, and an unquoted hash starts a comment.
Single quotes are literal characters. A flag without a value prints its
name. Multiple keys may be requested; missing keys print an empty line
and cause a nonzero exit status.

ENVIRONMENT VARIABLES
---------------------
Expand Down
173 changes: 130 additions & 43 deletions scripts/upsdrvsvcctl/nut-driver-enumerator.sh.in
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# is to just pick out some strings relevant for tracking config changes.
#
# Copyright (C) 2016-2020 Eaton
# Copyright (C) 2020-2025 Jim Klimov <jimklimov+nut@gmail.com>
# Copyright (C) 2020-2026 Jim Klimov <jimklimov+nut@gmail.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -502,7 +502,7 @@ upsconf_getSection_content() {
SECTION_CONTENT=""
RES=1
[ -n "$1" ] || RES=0
while read LINE ; do
while IFS= read -r LINE ; do
case "$LINE" in
\["$1"\])
if [ "$RES" = 0 ]; then
Expand Down Expand Up @@ -539,7 +539,7 @@ $LINE"
done

if [ "${#SECTION_CONTENT}" != 0 ]; then
echo "$SECTION_CONTENT"
printf '%s\n' "$SECTION_CONTENT"
fi

[ "$RES" = 0 ] || log_error "Section [$1] was not found in the '$UPSCONF' file"
Expand Down Expand Up @@ -570,6 +570,56 @@ ${UPSCONF_DATA_SDP}
EOF
}

upsconf_getValue_awk() {
# Helper for upsconf_getValue() below

# Like common/upsconf.c, take the first value token after the
# assignment. Quotes only open at token start; single quotes are
# ordinary characters. Decode backslash escapes exactly once.

NDE_KEY="$1" LC_ALL=C awk '
BEGIN { key = ENVIRON["NDE_KEY"] }
{
if (index($0, key "=") == 1) {
line = substr($0, length(key) + 2)
} else if ($0 == key || (index($0, key) == 1 &&
substr($0, length(key) + 1) ~ /^[ \t]*#/)) {
print key
found = 1
next
} else next

value = ""
started = quoted = escaped = 0
for (i = 1; i <= length(line); i++) {
c = substr(line, i, 1)
if (escaped) {
if (c ~ /^[ -\177]$/) value = value c
escaped = 0
} else if (c == "\\") {
escaped = started = 1
} else if (quoted) {
if (c == "\"") break
if (c ~ /^[ -\177]$/) value = value c
} else if (c == "#") break
else if (c ~ /^[ \t\r\n\013\014]$/) {
if (started) break
} else if (c == "=" && started) break
else if (c == "\"" && !started) {
quoted = started = 1
} else {
if (c ~ /^[ -\177]$/) value = value c
started = 1
if (c == "=") break
}
}
print value
found = 1
}
END { if (!found) exit 1 }
'
}

upsconf_getValue() {
# "$1" = name of ups.conf section, may be empty for global config
# "$2..$N" = name of config key; we will echo its value
Expand All @@ -579,8 +629,7 @@ upsconf_getValue() {
CURR_SECTION="" # Gets set by a GETSECTION implementation
RES=0

# Note: Primary aim of this egrep is to pick either assignments or flags
# As a by-product it can be used to test if a particular value is set ;)
# Keep the normalized syntax intact until this final value lookup.
SECTION_CONTENT="`$GETSECTION \"$1\"`" || return
shift
KEYS="$*"
Expand All @@ -589,13 +638,11 @@ upsconf_getValue() {
RES_L=0
VALUE=""

LINE="`echo \"$SECTION_CONTENT\" | $EGREP '(^'\"$1\"'=|^'\"$1\"'$)'`" \
&& VALUE="$(echo "$LINE" | $SED -e "s,^$1=,," -e 's,^\"\(.*\)\"$,\1,' -e "s,^'\(.*\)'\$,\1,")" \
|| RES_L=$?
VALUE="`printf '%s\n' \"${SECTION_CONTENT}\" | upsconf_getValue_awk \"$1\"`" || RES_L=$?

[ "$RES_L" = 0 ] || { RES="$RES_L" ; log_error "Section [$CURR_SECTION] or key '$1' in it was not found in the '$UPSCONF' file" ; }

echo "$VALUE"
printf '%s\n' "$VALUE"
shift
done

Expand Down Expand Up @@ -692,9 +739,9 @@ upsconf_getDriverMedia() {
CURR_PORT="`upsconf_getPort \"$1\"`" || CURR_PORT=""
case "$CURR_PORT" in
*@localhost|*@|*@127.0.0.1|*@::1)
printf '%s\n%s\n' "$CURR_DRV" "network-localhost,drivers=`echo "${CURR_PORT}" | $SED 's,@.*$,,'`" ; return ;;
printf '%s\n%s\n' "$CURR_DRV" "network-localhost,drivers=`printf '%s\n' \"${CURR_PORT}\" | $SED 's,@.*$,,'`" ; return ;;
*@"`hostname`"*) # Technically also local host, but via public host name (so networking must be up)
printf '%s\n%s\n' "$CURR_DRV" "network,drivers=`echo "${CURR_PORT}" | $SED 's,@.*$,,'`" ; return ;;
printf '%s\n%s\n' "$CURR_DRV" "network,drivers=`printf '%s\n' \"${CURR_PORT}\" | $SED 's,@.*$,,'`" ; return ;;
*@*) # Might be local host via public IP address, but harder to detect portably
printf '%s\n%s\n' "$CURR_DRV" "network" ; return ;;
*) # Assume DEV/SEQ file:
Expand All @@ -710,7 +757,7 @@ upsconf_getDriverMedia() {

upsconf_getMedia() {
_DRVMED="`upsconf_getDriverMedia \"$1\"`" || return
echo "${_DRVMED}" | @TAIL@ @TAIL_ARGS_FROM_NTH_LINE@ +2
printf '%s\n' "${_DRVMED}" | @TAIL@ @TAIL_ARGS_FROM_NTH_LINE@ +2
return 0
}

Expand Down Expand Up @@ -738,13 +785,13 @@ upsconf_debug() {
NAME_MD5="`calc_md5 \"$1\"`"
# Not public, not in usage()
# Used by tests, echo as formatted here to stdout
echo "INST: ${NAME_MD5}~[$1]: DRV='${_DRV}' PORT='${_PRT}' MEDIA='${_MED}' SECTIONMD5='${_MD5}'"
printf '%s\n' "INST: ${NAME_MD5}~[$1]: DRV='${_DRV}' PORT='${_PRT}' MEDIA='${_MED}' SECTIONMD5='${_MD5}'"
}

calc_md5() {
# Tries several ways to produce an MD5 of the "$1" argument
_MD5="`echo \"$1\" | md5sum 2>/dev/null | awk '{print $1}'`" && [ -n "${_MD5}" ] || \
{ _MD5="`echo \"$1\" | openssl dgst -md5 2>/dev/null | awk '{print $NF}'`" && [ -n "${_MD5}" ]; } || \
_MD5="`printf '%s\n' \"$1\" | md5sum 2>/dev/null | awk '{print $1}'`" && [ -n "${_MD5}" ] || \
{ _MD5="`printf '%s\n' \"$1\" | openssl dgst -md5 2>/dev/null | awk '{print $NF}'`" && [ -n "${_MD5}" ]; } || \
return 1

echo "${_MD5}"
Expand All @@ -768,7 +815,7 @@ smf_validFullUnitName() {
esac
}
smf_validInstanceName() {
echo "MD5_`calc_md5 "$1"`"
echo "MD5_`calc_md5 \"$1\"`"
}
smf_validInstanceSuffixName() {
case "$1" in
Expand Down Expand Up @@ -826,7 +873,7 @@ smf_registerInstance() {
for OTHERDEV in $UPSLIST_FILE ; do
case "${DEPDRV}" in
*-"${OTHERDEV}")
OTHERDRV="'`echo "${OTHERLIST}" | awk '($1 == "'"${OTHERDEV}"'") { print $2 }'`'" || OTHERDRV="<N/A>"
OTHERDRV="'`echo \"${OTHERLIST}\" | awk '($1 == \"'\"${OTHERDEV}\"'\") { print $2 }'`'" || OTHERDRV="<N/A>"
log_warn "Device ${DEVICE} depends on another as '${DEPDRV}', but the other possible device '${OTHERDEV}' uses an unexpected driver: ${OTHERDRV}"
;;
esac
Expand Down Expand Up @@ -1047,7 +1094,7 @@ systemd_validFullUnitName() {
esac
}
systemd_validInstanceName() {
echo "MD5_`calc_md5 "$1"`"
echo "MD5_`calc_md5 \"$1\"`"
}
systemd_validInstanceSuffixName() {
echo "$1" | $SED -e 's,^.*@,,' -e 's,\.service$,,'
Expand Down Expand Up @@ -1112,7 +1159,7 @@ systemd_registerInstance() {
for OTHERDEV in $UPSLIST_FILE ; do
case "${DEPDRV}" in
*-"${OTHERDEV}")
OTHERDRV="'`echo "${OTHERLIST}" | awk '($1 == "'"${OTHERDEV}"'") { print $2 }'`'" || OTHERDRV="<N/A>"
OTHERDRV="'`echo \"${OTHERLIST}\" | awk '($1 == \"'\"${OTHERDEV}\"'\") { print $2 }'`'" || OTHERDRV="<N/A>"
log_warn "Device ${DEVICE} depends on another as '${DEPDRV}', but the other possible device '${OTHERDEV}' uses an unexpected driver: ${OTHERDRV}"
;;
esac
Expand Down Expand Up @@ -1291,25 +1338,65 @@ upslist_normalizeFile_filter() {
# See upslist_normalizeFile() detailed comments below; this routine
# is a pipe worker to prepare the text into a simpler expected form.

# Pick the lines which contain a bracket or assignment character,
# or a single token (certain keywords come as just NUT "flags"),
# trim leading and trailing whitespace, comment-only lines, and in
# assignment lines trim the spaces around equality character and
# quoting characters around assignment of values without whitespaces.
# Any whitespace characters around a section name (single token that
# starts the line and is enclosed in brackets) and a trailing comment
# are dropped. Note that brackets with spaces inside, and brackets
# that do not start the non-whitespace payload of the line, are not
# sections.
$EGREP -v '(^$|^#)' | \
$SED -e 's,^['"$TABCHAR"'\ ]*,,' \
-e 's,^\#.*$,,' \
-e 's,['"$TABCHAR"'\ ]*$,,' \
-e 's,^\([^=\ '"$TABCHAR"']*\)['"$TABCHAR"'\ ]*=['"$TABCHAR"'\ ]*,\1=,g' \
-e 's,=\"\([^\ '"$TABCHAR"']*\)\"$,=\1,' \
-e 's,^\(\[[^]'"$TABCHAR"'\ ]*\]\)['"$TABCHAR"'\ ]*\(#.*\)*$,\1,' \
| $EGREP -v '^$' \
| $EGREP '([\[\=]|^[^ '"$TABCHAR"']*$|^[^ '"$TABCHAR"']*[ '"$TABCHAR"']*#.*$)'
# Preserve quotes and escapes for upsconf_getValue(). Join physical
# continuations, and trim only whitespace outside tokens, so escaped
# trailing spaces survive. Keep the existing normalized text format
# (not a serialization of decoded values) for section checksums.
awk '
BEGIN { start = 1 }
{
offset = length(line)
line = line $0
for (i = 1; i <= length($0); i++) {
c = substr($0, i, 1)
if (comment) {
if (c !~ /^[ \t]$/) last = offset + i
} else if (escaped) {
last = offset + i
escaped = 0
} else if (c == "\\") {
last = offset + i
escaped = 1
start = 0
} else if (quoted) {
last = offset + i
if (c == "\"") { quoted = 0; start = 1 }
} else if (c == "#") {
last = offset + i
comment = 1
} else if (c ~ /^[ \t]$/) start = 1
else {
last = offset + i
if (c == "\"" && start) quoted = 1
start = (c == "=")
}
}
if (escaped) {
line = substr(line, 1, length(line) - 1)
last = length(line)
escaped = 0
next
}
# Like parseconf, ignore physical newlines inside quotes.
if (quoted) next
line = substr(line, 1, last)
sub(/^[ \t]*/, "", line)
# Normalize only the assignment prefix, never its value.
if (match(line, /^[^= \t]+[ \t]*=[ \t]*/)) {
prefix = substr(line, 1, RLENGTH)
gsub(/[ \t]/, "", prefix)
line = prefix substr(line, RLENGTH + 1)
}
if (line ~ /^\[[^] \t]*\][ \t]*(#.*)?$/)
sub(/\][ \t]*(#.*)?$/, "]", line)
if (line !~ /^#/ && (index(line, "[") || index(line, "=") ||
line ~ /^[^ \t]+([ \t]*#.*)?$/)) print line
line = ""
last = quoted = comment = 0
start = 1
}
END { if (line != "") print line }
'
}

upslist_normalizeFile() {
Expand All @@ -1335,7 +1422,7 @@ upslist_normalizeFile() {
# Store a normalized version of NUT configuration file contents.
# Also use a SDP subset with just section, driver and port info
# for faster parsing when determining driver-required media etc.
UPSCONF_DATA="$(upslist_normalizeFile_filter < "$UPSCONF")" \
UPSCONF_DATA="`upslist_normalizeFile_filter < \"${UPSCONF}\"`" \
&& [ "${#UPSCONF_DATA}" != 0 ] \
&& UPSCONF_DATA_SDP="`$EGREP '^(\[.*\]|driver=|port=)' << EOF
$UPSCONF_DATA
Expand Down Expand Up @@ -1368,7 +1455,7 @@ upslist_readFile() {

if [ "${#UPSCONF_DATA}" != 0 ] ; then
# Note that section-name brackets should contain a single token
UPSLIST_FILE="$(echo "$UPSCONF_DATA_SDP" | $EGREP '^\[[^'"$TABCHAR"'\ ]*\]$' | $SED 's,^\[\(.*\)\]$,\1,' | sort -k1n -k1)" \
UPSLIST_FILE="`printf '%s\n' \"$UPSCONF_DATA_SDP\" | $EGREP '^\[[^'\"$TABCHAR\"'\ ]*\]$' | $SED 's,^\[\(.*\)\]$,\1,' | sort -k1n -k1`" \
|| UPSLIST_FILE=""
if [ "${#UPSLIST_FILE}" = 0 ] ; then
log_error "Could not read the '$UPSCONF' file or it does not declare any device configurations: no section declarations in parsed normalized contents"
Expand Down Expand Up @@ -1551,7 +1638,7 @@ nut_driver_enumerator_main() {

if [ "$RESTART_ALL" = yes ] ; then
# Save new checksum of global config
$hook_setSavedMD5 "" "`upsconf_getSection_MD5 ""`"
$hook_setSavedMD5 "" "`upsconf_getSection_MD5 ''`"
fi

if [ "${#UPSLIST_FILE}" != 0 ]; then
Expand Down Expand Up @@ -1628,7 +1715,7 @@ nut_driver_enumerator_full_reconfigure() {
fi

# Save new checksum of global config
$hook_setSavedMD5 "" "`upsconf_getSection_MD5 ""`"
$hook_setSavedMD5 "" "`upsconf_getSection_MD5 ''`"

# Service units were manipulated, including saving of checksums;
# refresh the service management daemon if needed
Expand Down Expand Up @@ -2056,7 +2143,7 @@ while [ $# -gt 0 ]; do
[ "${#UPSLIST_FILE}" != 0 ] \
|| { log_warn "No devices detected in '$UPSCONF'" ; RES=1 ; }
# echo this to stdout:
echo "$UPSCONF_DATA"
printf '%s\n' "$UPSCONF_DATA"
exit $RES
;;
--show-config|--show-device-config)
Expand Down
Loading
Loading