Skip to content

Commit 35cfb0f

Browse files
authored
Merge c925c96 into 2caa3c8
2 parents 2caa3c8 + c925c96 commit 35cfb0f

10 files changed

Lines changed: 458 additions & 29 deletions

File tree

NEWS.adoc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,12 @@ https://github.com/networkupstools/nut/milestone/13
439439
not do so for single-driver runs -- addressed with this release. [#3302]
440440

441441
- common code:
442+
* The configuration and protocol parser now accepts a literal `#`
443+
inside double quotes, including in UPS and `dummy-ups` outlet
444+
descriptions. Unquoted `#` still starts a comment, and escaped
445+
hashes remain supported. Encoded output continues to escape hashes
446+
for older peers; default `upsc` text output is unchanged.
447+
[issues #607, #1306]
442448
* POSIX daemons now warn if their real or effective UID remains zero after
443449
the common credential switch. [issue #3471, PR #3609]
444450
* Refactored `common::background()` method used by numerous NUT daemons

common/parseconf.c

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -283,26 +283,9 @@ static int findeol(PCONF_CTX_t *ctx)
283283
return STATE_FINDEOL;
284284
}
285285

286-
/* set up the error reporting details */
287-
static void pconf_seterr(PCONF_CTX_t *ctx, const char *errmsg)
288-
{
289-
snprintf(ctx->errmsg, PCONF_ERR_LEN, "%s", errmsg);
290-
291-
ctx->error = 1;
292-
}
293-
294286
/* quote characters inside a word bounded by "quotes" */
295287
static int quotecollect(PCONF_CTX_t *ctx)
296288
{
297-
/* user is trying to break us */
298-
if (ctx->ch == '#') {
299-
pconf_seterr(ctx, "Unbalanced word due to unescaped # in quotes");
300-
endofword(ctx);
301-
302-
/* this makes us drop all the way out of the caller */
303-
return STATE_PARSEERR;
304-
}
305-
306289
/* another " means we're done with this word */
307290
if (ctx->ch == '"') {
308291
endofword(ctx);

docs/config-notes.txt

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ Details about the configuration files
3535
Generalities
3636
~~~~~~~~~~~~
3737

38-
All configuration files within this package are parsed with a common
39-
state machine, which means they all can use a number of extras described here.
38+
Most configuration files within this package are parsed with a common
39+
state machine, which means they can use a number of extras described here.
40+
The service configuration file linkman:nut.conf[5] has its own syntax
41+
requirements, described in its manual page.
4042

4143
First, most of the programs use an upper-case word to declare a
4244
configuration directive. This may be something like MONITOR, NOTIFYCMD,
@@ -45,13 +47,19 @@ or ACCESS. The case does matter here. "monitor" won't be recognized.
4547
Next, the parser does not care about whitespace between words. If you
4648
like to indent things with tabs or spaces, feel free to do it here.
4749

48-
If you need to set a value to something containing spaces, it has to be
49-
contained within "quotes" to keep the parser from splitting up the line.
50+
If you need to set a value to something containing spaces, put it within
51+
double quotes or escape each space with a backslash to keep the parser
52+
from splitting up the line.
5053
That is, you want to use something like this:
5154

5255
SHUTDOWNCMD "/sbin/shutdown -h +0"
5356

54-
Without the quotes, it would only see the first word on the line.
57+
Without quoting or escaping the spaces, a directive expecting one value
58+
would only see the first word of that value.
59+
60+
Double quotes must begin at the start of a token. A closing double quote
61+
completes the token: `"one"two` yields two tokens, `one` and `two`.
62+
Single quotes (apostrophes) are ordinary characters and do not group words.
5563

5664
OK, so let's say you really need to embed that kind of quote within your
5765
configuration directive for some reason. You can do that too.
@@ -65,27 +73,35 @@ string, you just escape it.
6573

6674
NOTIFYCMD "/bin/notifyme c:\\dos\\style\\path"
6775

68-
The `\` can actually be used to escape any character, but you only really
69-
need it for `\`, `"`, and `#` as they have special meanings to the parser.
76+
The `\` can actually be used to escape any permitted character, inside or
77+
outside double quotes. It quotes the next character once: `\n` means the
78+
letter `n`, not a newline. It is useful for `\`, `"`, and, outside double
79+
quotes, spaces, `=`, and `#`, which have special meanings to the parser.
7080

7181
When using file names with space characters, you may end up having tricky
7282
things since you need to write them inside `""` which must be escaped:
7383

7484
NOTIFYCMD "\"c:\\path with space\\notifyme\" \"c:\\path with space\\name\""
7585

76-
`#` is the comment character. Anything after an unescaped `#` is ignored.
86+
Outside double quotes, `#` is the comment character. Anything after an
87+
unescaped `#` is ignored until the end of the physical line.
7788

7889
Something like this...
7990

8091
identity = my#1ups
8192

8293
will actually turn into `identity = my`, since the `#` stops the
8394
parsing. If you really need to have a `#` in your configuration, then
84-
escape it.
95+
escape it or put the value inside double quotes.
8596

8697
identity = my\#1ups
8798

88-
Much better.
99+
or:
100+
101+
identity = "my#1ups"
102+
103+
Older versions of the parser required `\#` even inside double quotes.
104+
That spelling remains supported for compatibility with those versions.
89105

90106
The `=` character should be used with care too. There should be only one
91107
"simple" `=` character in a line: between the parameter name and its value.

docs/man/dummy-ups.txt

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,32 @@ This definition file, specified by the `port` argument in the example above,
141141
is generally named `something.dev` or `something.seq`. It contains a list of
142142
all valid variables and associated values (you can later use `upsrw`
143143
only to modify values of these variables), and has the same format as an
144-
linkman:upsc[8] data dump (`<varname>: <value>`). This means you can easily
145-
create definition files from an existing UPS using `upsc > file.dev`.
144+
linkman:upsc[8] data dump (`<varname>: <value>`). You can use
145+
`upsc > file.dev` as a starting point for a definition file.
146+
147+
Values are read with the common NUT configuration parser. Unquoted value
148+
tokens are joined with a single space; double quotes preserve spaces
149+
inside a token. Single quotes (apostrophes) are literal characters.
150+
An unescaped `#` outside double quotes starts a comment, while a `#`
151+
inside double quotes is literal. For example:
152+
153+
outlet.1.desc: Outlet #1
154+
outlet.2.desc: "Outlet \#2"
155+
outlet.3.desc: "Outlet #3"
156+
outlet.4.desc: Outlet \#4
157+
158+
These values become `Outlet`, `Outlet #2`, `Outlet #3`, and `Outlet #4`,
159+
respectively. Older versions of the parser require `\#` even inside
160+
double quotes. Backslashes quote the next permitted character once, so
161+
`\n` becomes `n`, while `\\` becomes a single literal backslash.
162+
A backslash followed by a physical newline continues the logical line.
163+
An unescaped `=` outside double quotes is a separate token; quote or
164+
escape it when it is part of a value.
165+
166+
The default `upsc` output does not add quoting or escaping. Before reading
167+
such a dump back with *dummy-ups*, quote or escape values containing these
168+
special characters as needed. Its JSON output preserves string values
169+
unambiguously, but JSON is not a *dummy-ups* definition file format.
146170

147171
Note that the Network UPS project provides an extensive
148172
link:https://www.networkupstools.org/ddl/index.html[DDL (Devices Dumps Library)]

docs/net-protocol.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ Embedded quotes are escaped with backslashes. Embedded backslashes are
4444
also escaped by representing them as \\. This protocol is intended to
4545
be interpreted with parseconf (NUT parser) or something similar.
4646

47+
Double quotes start a quoted element only at the beginning of an element.
48+
The closing quote completes that element, so `"one"two` produces two
49+
elements, `one` and `two`. Single quotes (apostrophes) are ordinary
50+
characters; they do not group words. Outside double quotes, unescaped
51+
whitespace separates elements, and an unescaped `=` becomes a separate
52+
element of its own.
53+
54+
An unescaped `#` starts a comment only outside double quotes. Inside
55+
double quotes it is literal, so `"Outlet #3"` represents `Outlet #3`.
56+
For compatibility with older parsers which rejected an unescaped `#`
57+
inside quotes, `pconf_encode()` continues to send it as `\#`.
58+
59+
A backslash quotes the next character once, inside or outside double
60+
quotes, subject to the character restrictions of the parser. For example,
61+
`one\ two` is one element, while `\n` represents the letter `n`, not a
62+
newline. A backslash followed by a physical newline continues the logical
63+
line. These rules are NUT syntax, not shell or JSON string syntax.
64+
4765

4866
Revision history
4967
----------------

tests/Makefile.am

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ nodist_test_cgilib_SOURCES = cgilib.c
9999
test_cgilib_CFLAGS = $(AM_CFLAGS) -I$(top_srcdir)/clients
100100
test_cgilib_LDADD = $(NUT_LIBCOMMON)
101101

102+
TESTS += parseconf-test
103+
parseconf_test_SOURCES = parseconf-test.c
104+
parseconf_test_LDADD = $(NUT_LIBCOMMON)
105+
102106
nutlogtest_SOURCES = nutlogtest.c
103107
nutlogtest_LDADD = $(NUT_LIBCOMMON)
104108

tests/NIT/Makefile.am

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@NUT_AM_MAKE_CAN_EXPORT@@NUT_AM_EXPORT_CCACHE_PATH@export PATH=@PATH_DURING_CONFIGURE@
1111

1212
EXTRA_DIST = nit.sh README.adoc
13+
EXTRA_DIST += parseconf-test.py
1314

1415
if WITH_CHECK_NIT
1516
check: check-NIT

tests/NIT/nit.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4728,6 +4728,41 @@ testgroup_sandbox_python() {
47284728
sandbox_forget_configs
47294729
}
47304730

4731+
testgroup_sandbox_parseconf() {
4732+
# Start a fresh sandbox so these punctuation fixtures do not change
4733+
# the model, timer and language-binding tests' expected data.
4734+
if ! isTestablePython || [ -z "${PYTHON}" ] \
4735+
|| ! $PYTHON -c 'import json' >/dev/null 2>&1 ; then
4736+
if [ x"${NIT_CASE}" = xtestgroup_sandbox_parseconf ]; then
4737+
log_error "[testgroup_sandbox_parseconf] Python with the json module is required"
4738+
FAILED="`expr $FAILED + 1`"
4739+
FAILED_FUNCS="$FAILED_FUNCS testgroup_sandbox_parseconf:missing-prerequisites"
4740+
else
4741+
log_warn "[testgroup_sandbox_parseconf] SKIPPED: Python with the json module is unavailable"
4742+
SKIPPED="`expr $SKIPPED + 1`"
4743+
SKIPPED_FUNCS="$SKIPPED_FUNCS testgroup_sandbox_parseconf"
4744+
fi
4745+
return
4746+
fi
4747+
4748+
stop_daemons
4749+
sandbox_forget_configs
4750+
sandbox_generate_configs
4751+
$PYTHON "${TOP_SRCDIR}/tests/NIT/parseconf-test.py" prepare "$NUT_CONFPATH" \
4752+
|| die "[testgroup_sandbox_parseconf] Could not prepare parser fixtures"
4753+
testcase_sandbox_start_drivers_after_upsd
4754+
4755+
if $PYTHON "${TOP_SRCDIR}/tests/NIT/parseconf-test.py" \
4756+
"${TOP_BUILDDIR}/clients/upsc${EXEEXT}" "$NUT_PORT" ; then
4757+
PASSED="`expr $PASSED + 1`"
4758+
log_info "[testgroup_sandbox_parseconf] PASSED: exact text and decoded JSON values"
4759+
else
4760+
FAILED="`expr $FAILED + 1`"
4761+
FAILED_FUNCS="$FAILED_FUNCS testgroup_sandbox_parseconf"
4762+
fi
4763+
sandbox_forget_configs
4764+
}
4765+
47314766
testgroup_sandbox_perl() {
47324767
# Arrange for quick test iterations
47334768
testcase_sandbox_start_drivers_after_upsd
@@ -4854,6 +4889,7 @@ case "${NIT_CASE}" in
48544889
testgroup_upsd_invalid_configs
48554890
testgroup_upsd_questionable_configs
48564891
testgroup_sandbox
4892+
testgroup_sandbox_parseconf
48574893
;;
48584894
*) die "Unsupported NIT_CASE='$NIT_CASE' was requested" ;;
48594895
esac

tests/NIT/parseconf-test.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env python
2+
# Exact values through configuration, dummy-ups, upsd and upsc.
3+
# Copyright (C) 2026 Network UPS Tools project
4+
# SPDX-License-Identifier: GPL-2.0-or-later
5+
6+
from __future__ import print_function
7+
8+
import json
9+
import os
10+
import subprocess
11+
import sys
12+
import threading
13+
14+
15+
# Input is NUT syntax; expected values are literal decoded strings.
16+
VALUES = [
17+
('Outlet #1', 'Outlet'),
18+
('"Outlet \\#2"', 'Outlet #2'),
19+
('"Outlet #3"', 'Outlet #3'),
20+
('Outlet \\#4', 'Outlet #4'),
21+
('"\\"edge\\" C:\\\\ups #5"', '"edge" C:\\ups #5'),
22+
('one\\ two \\n \\t \\x41', 'one two n t x41'),
23+
("'single quotes'", "'single quotes'"),
24+
('"a=b:c two spaces"', 'a=b:c two spaces'),
25+
('prefix\\\nsuffix', 'prefixsuffix'),
26+
('""', None), # dummy-ups deletes variables assigned an empty value.
27+
('"a\\\\#b"', 'a\\#b'),
28+
('"a\\\\\\"b"', 'a\\"b'),
29+
]
30+
DESCRIPTION_INPUT = '"Parser #1: \\"quoted\\" C:\\\\ups\'s"'
31+
DESCRIPTION = 'Parser #1: "quoted" C:\\ups\'s'
32+
33+
34+
def prepare(confpath):
35+
path = os.path.join(confpath, 'ups.conf')
36+
with open(path, 'r') as source:
37+
config = source.read()
38+
original = 'desc = "Crash Dummy"'
39+
if config.count(original) != 1:
40+
raise AssertionError('expected one fresh NIT dummy configuration')
41+
with open(path, 'w') as target:
42+
target.write(config.replace(original, 'desc = ' + DESCRIPTION_INPUT))
43+
with open(os.path.join(confpath, 'dummy.seq'), 'w') as target:
44+
target.write('ups.status: OL\n')
45+
for index, (value, expected) in enumerate(VALUES):
46+
if expected is None:
47+
target.write('outlet.%d.desc: before empty\n' % (index + 1))
48+
target.write('outlet.%d.desc: %s\n' % (index + 1, value))
49+
target.write('TIMER 60\n')
50+
51+
52+
def query(upsc, *args):
53+
process = subprocess.Popen([upsc] + list(args), stdout=subprocess.PIPE,
54+
stderr=subprocess.PIPE)
55+
# Python 2.6 also runs NIT; communicate(timeout=...) is newer.
56+
timer = threading.Timer(10, process.kill)
57+
timer.start()
58+
try:
59+
output, error = process.communicate()
60+
finally:
61+
timer.cancel()
62+
timer.join()
63+
if process.returncode:
64+
raise AssertionError('upsc %r returned %d: %r' %
65+
(args, process.returncode, error))
66+
# Fixtures are ASCII. Normalize only the platform's output line ending.
67+
return output.decode('ascii').replace('\r\n', '\n')
68+
69+
70+
def equal(actual, expected):
71+
if actual != expected:
72+
raise AssertionError('got %r, expected %r' % (actual, expected))
73+
74+
75+
def check(upsc, port):
76+
host = '127.0.0.1:' + port
77+
ups = 'dummy@' + host
78+
plain = query(upsc, ups).splitlines()
79+
data = json.loads(query(upsc, '-j', ups))
80+
for index, (value, expected) in enumerate(VALUES):
81+
key = 'outlet.%d.desc' % (index + 1)
82+
if expected is None:
83+
equal([line for line in plain if line.startswith(key + ':')], [])
84+
equal(key in data, False)
85+
print('PASS: empty value removes %s from text and JSON lists' % key)
86+
continue
87+
equal([line for line in plain if line.startswith(key + ':')],
88+
[key + ': ' + expected])
89+
equal(data[key], expected)
90+
equal(query(upsc, ups, key), expected + '\n')
91+
equal(json.loads(query(upsc, '-j', ups, key)), expected)
92+
print('PASS: %s = %r (text and JSON, list and single value)' %
93+
(key, expected))
94+
95+
names = query(upsc, '-l', host).splitlines()
96+
equal(json.loads(query(upsc, '-j', '-l', host)), names)
97+
descriptions = json.loads(query(upsc, '-j', '-L', host))
98+
equal(sorted(descriptions), sorted(names))
99+
equal(descriptions['dummy'], DESCRIPTION)
100+
equal([line for line in query(upsc, '-L', host).splitlines()
101+
if line.startswith('dummy:')], ['dummy: ' + DESCRIPTION])
102+
# These read-only clients do not LOGIN to the UPS.
103+
equal(query(upsc, '-c', ups).splitlines(), [])
104+
equal(json.loads(query(upsc, '-j', '-c', ups)), [])
105+
print('PASS: configured description, UPS lists and empty client lists')
106+
107+
108+
if __name__ == '__main__':
109+
if sys.argv[1] == 'prepare':
110+
prepare(sys.argv[2])
111+
else:
112+
check(sys.argv[1], sys.argv[2])

0 commit comments

Comments
 (0)