diff --git a/NEWS.adoc b/NEWS.adoc index d2b9679ced..126ad2653c 100644 --- a/NEWS.adoc +++ b/NEWS.adoc @@ -447,10 +447,16 @@ https://github.com/networkupstools/nut/milestone/13 not do so for single-driver runs -- addressed with this release. [#3302] - common code: + * The configuration and protocol parser now accepts a literal `#` + inside double quotes, including in UPS and `dummy-ups` outlet + descriptions. Unquoted `#` still starts a comment, and escaped + hashes remain supported. Encoded output continues to escape hashes + for older peers; default `upsc` text output is unchanged. + [issues #607, #1306, PR #3633] * The common state store now retains value changes which differ only in letter case, including updates to opaque strings such as `device.contact`. Variable names remain case-insensitive and immutable values remain - protected from changes. [issue #3622] + protected from changes. [issue #3622, PR #3630] * POSIX daemons now warn if their real or effective UID remains zero after the common credential switch. [issue #3471, PR #3609] * Refactored `common::background()` method used by numerous NUT daemons diff --git a/UPGRADING.adoc b/UPGRADING.adoc index cc0095fafc..d51b3cbf68 100644 --- a/UPGRADING.adoc +++ b/UPGRADING.adoc @@ -41,12 +41,19 @@ Changes from 2.8.5 to 2.8.6 - PLANNED: Keep track of any further API clean-up? +- The common configuration and protocol parser now accepts literal `#` + characters inside double quotes. An unescaped `#` outside double quotes + still starts a comment. Existing `\#` escapes remain supported and are + still needed when sharing configuration files with older NUT versions. + Encoded protocol output continues to escape hashes for older peers; + default `upsc` text output is unchanged. [issues #607, #1306, PR #3633] + - Correct malformed numeric values in `upsd.conf` when upgrading: `MAXAGE`, `TRACKINGDELAY`, `MAXCONN` and numeric `CERTREQUEST` values are now checked in full and must fit their destination ranges. Values such as `15seconds` were previously read as `15`; these and overflowing values are now logged - and ignored, retaining the previous setting. Valid decimal values, - leading zeroes and trailing whitespace remain accepted. [issue #676] + and ignored, retaining the previous setting. Valid decimal values, leading + zeroes and trailing whitespace remain accepted. [issue #676, PR #3631] - Potentially a breaking change for C++ clients that rushed to use the new SSL support options in `libnutclient`: for Mozilla NSS setup, the way to diff --git a/common/parseconf.c b/common/parseconf.c index fad1b2fbe3..a46888f106 100644 --- a/common/parseconf.c +++ b/common/parseconf.c @@ -283,26 +283,19 @@ static int findeol(PCONF_CTX_t *ctx) return STATE_FINDEOL; } -/* set up the error reporting details */ +#if 0 +/* Retained for future syntax errors; quoted hashes are now valid. */ static void pconf_seterr(PCONF_CTX_t *ctx, const char *errmsg) { snprintf(ctx->errmsg, PCONF_ERR_LEN, "%s", errmsg); ctx->error = 1; } +#endif /* quote characters inside a word bounded by "quotes" */ static int quotecollect(PCONF_CTX_t *ctx) { - /* user is trying to break us */ - if (ctx->ch == '#') { - pconf_seterr(ctx, "Unbalanced word due to unescaped # in quotes"); - endofword(ctx); - - /* this makes us drop all the way out of the caller */ - return STATE_PARSEERR; - } - /* another " means we're done with this word */ if (ctx->ch == '"') { endofword(ctx); diff --git a/docs/config-notes.txt b/docs/config-notes.txt index f06450c3e9..a84a4a1d52 100644 --- a/docs/config-notes.txt +++ b/docs/config-notes.txt @@ -35,8 +35,10 @@ Details about the configuration files Generalities ~~~~~~~~~~~~ -All configuration files within this package are parsed with a common -state machine, which means they all can use a number of extras described here. +Most configuration files within this package are parsed with a common +state machine, which means they can use a number of extras described here. +The service configuration file linkman:nut.conf[5] has its own syntax +requirements, described in its manual page. First, most of the programs use an upper-case word to declare a configuration directive. This may be something like MONITOR, NOTIFYCMD, @@ -45,13 +47,23 @@ or ACCESS. The case does matter here. "monitor" won't be recognized. Next, the parser does not care about whitespace between words. If you like to indent things with tabs or spaces, feel free to do it here. -If you need to set a value to something containing spaces, it has to be -contained within "quotes" to keep the parser from splitting up the line. +If you need to set a value to something containing spaces, put it within +double quotes or escape each space with a backslash to keep the parser +from splitting up the line. That is, you want to use something like this: SHUTDOWNCMD "/sbin/shutdown -h +0" -Without the quotes, it would only see the first word on the line. +or, with each space escaped: + + SHUTDOWNCMD /sbin/shutdown\ -h\ +0 + +Without quoting or escaping the spaces, a directive expecting one value +would only see the first word of that value. + +Double quotes must begin at the start of a token. A closing double quote +completes the token: `"one"two` yields two tokens, `one` and `two`. +Single quotes (apostrophes) are ordinary characters and do not group words. OK, so let's say you really need to embed that kind of quote within your configuration directive for some reason. You can do that too. @@ -65,15 +77,18 @@ string, you just escape it. NOTIFYCMD "/bin/notifyme c:\\dos\\style\\path" -The `\` can actually be used to escape any character, but you only really -need it for `\`, `"`, and `#` as they have special meanings to the parser. +The `\` can actually be used to escape any permitted character, inside or +outside double quotes. It quotes the next character once: `\n` means the +letter `n`, not a newline. It is useful for `\`, `"`, and, outside double +quotes, spaces, `=`, and `#`, which have special meanings to the parser. When using file names with space characters, you may end up having tricky things since you need to write them inside `""` which must be escaped: NOTIFYCMD "\"c:\\path with space\\notifyme\" \"c:\\path with space\\name\"" -`#` is the comment character. Anything after an unescaped `#` is ignored. +Outside double quotes, `#` is the comment character. Anything after an +unescaped `#` is ignored until the end of the physical line. Something like this... @@ -81,11 +96,16 @@ Something like this... will actually turn into `identity = my`, since the `#` stops the parsing. If you really need to have a `#` in your configuration, then -escape it. +escape it or put the value inside double quotes. identity = my\#1ups -Much better. +or: + + identity = "my#1ups" + +Older versions of the parser required `\#` even inside double quotes. +That spelling remains supported for compatibility with those versions. The `=` character should be used with care too. There should be only one "simple" `=` character in a line: between the parameter name and its value. diff --git a/docs/man/dummy-ups.txt b/docs/man/dummy-ups.txt index e7dedff4a1..b2f1648b15 100644 --- a/docs/man/dummy-ups.txt +++ b/docs/man/dummy-ups.txt @@ -141,8 +141,38 @@ This definition file, specified by the `port` argument in the example above, is generally named `something.dev` or `something.seq`. It contains a list of all valid variables and associated values (you can later use `upsrw` only to modify values of these variables), and has the same format as an -linkman:upsc[8] data dump (`: `). This means you can easily -create definition files from an existing UPS using `upsc > file.dev`. +linkman:upsc[8] data dump (`: `). You can use +`upsc > file.dev` as a starting point for a definition file. + +Values are read with the common NUT configuration parser. Unquoted value +tokens are joined with a single space; double quotes preserve spaces +inside a token. Single quotes (apostrophes) are literal characters. +An unescaped `#` outside double quotes starts a comment, while a `#` +inside double quotes is literal. For example: + + outlet.1.desc: Outlet #1 + outlet.2.desc: "Outlet \#2" + outlet.3.desc: "Outlet #3" + outlet.4.desc: Outlet \#4 + +These values become `Outlet`, `Outlet #2`, `Outlet #3`, and `Outlet #4`, +respectively. The last example has two value tokens, `Outlet` and `#4`, +which *dummy-ups* joins with a space. Escaping that space makes one token +with the same value: + + outlet.4.desc: Outlet\ \#4 + +Older versions of the parser require `\#` even inside +double quotes. Backslashes quote the next permitted character once, so +`\n` becomes `n`, while `\\` becomes a single literal backslash. +A backslash followed by a physical newline continues the logical line. +An unescaped `=` outside double quotes is a separate token; quote or +escape it when it is part of a value. + +The default `upsc` output does not add quoting or escaping. Before reading +such a dump back with *dummy-ups*, quote or escape values containing these +special characters as needed. Its JSON output preserves string values +unambiguously, but JSON is not a *dummy-ups* definition file format. Note that the Network UPS project provides an extensive link:https://www.networkupstools.org/ddl/index.html[DDL (Devices Dumps Library)] diff --git a/docs/net-protocol.txt b/docs/net-protocol.txt index 0bf14ded61..ce3881a5c5 100644 --- a/docs/net-protocol.txt +++ b/docs/net-protocol.txt @@ -67,6 +67,24 @@ be interpreted with parseconf (NUT parser) or something similar. It is an ASCII text-based line-oriented protocol, with end of each line signaled by a single `\n` (`0x0D`) character. +Double quotes start a quoted element only at the beginning of an element. +The closing quote completes that element, so `"one"two` produces two +elements, `one` and `two`. Single quotes (apostrophes) are ordinary +characters; they do not group words. Outside double quotes, unescaped +whitespace separates elements, and an unescaped `=` becomes a separate +element of its own. + +An unescaped `#` starts a comment only outside double quotes. Inside +double quotes it is literal, so `"Outlet #3"` represents `Outlet #3`. +For compatibility with older parsers which rejected an unescaped `#` +inside quotes, `pconf_encode()` continues to send it as `\#`. + +A backslash quotes the next character once, inside or outside double +quotes, subject to the character restrictions of the parser. For example, +`one\ two` is one element, while `\n` represents the letter `n`, not a +newline. A backslash followed by a physical newline continues the logical +line. These rules are NUT syntax, not shell or JSON string syntax. + Revision history ---------------- diff --git a/tests/Makefile.am b/tests/Makefile.am index 9a2a26b307..f6f0c55513 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -100,6 +100,10 @@ nodist_test_cgilib_SOURCES = cgilib.c test_cgilib_CFLAGS = $(AM_CFLAGS) -I$(top_srcdir)/clients test_cgilib_LDADD = $(NUT_LIBCOMMON) +TESTS += parseconf-test +parseconf_test_SOURCES = parseconf-test.c +parseconf_test_LDADD = $(NUT_LIBCOMMON) + TESTS += test_upsdconf test_upsdconf_SOURCES = test_upsdconf.c test_upsdconf_CFLAGS = $(AM_CFLAGS) -I$(top_srcdir)/server diff --git a/tests/NIT/Makefile.am b/tests/NIT/Makefile.am index 21d2319ee3..d8ab932100 100644 --- a/tests/NIT/Makefile.am +++ b/tests/NIT/Makefile.am @@ -10,6 +10,7 @@ @NUT_AM_MAKE_CAN_EXPORT@@NUT_AM_EXPORT_CCACHE_PATH@export PATH=@PATH_DURING_CONFIGURE@ EXTRA_DIST = nit.sh README.adoc +EXTRA_DIST += parseconf-test.py if WITH_CHECK_NIT check: check-NIT diff --git a/tests/NIT/nit.sh b/tests/NIT/nit.sh index 71f061dbda..738586886a 100755 --- a/tests/NIT/nit.sh +++ b/tests/NIT/nit.sh @@ -24,6 +24,7 @@ # NUT_DEBUG_LEVEL_UPSSCHED=3 to set debug level for particular # NUT daemons or tools # NUT_PORT=12345 custom port for upsd to listen and clients to query +# NIT_PARSECONF_TIMEOUT=60 per-query watchdog seconds for parser tests # NUT_FOREGROUND_WITH_PID=true default foregrounding is without # PID files, this option tells daemons to save them # TESTDIR=/tmp/nut-NIT to propose a location for "etc" and "run" @@ -4759,6 +4760,64 @@ testgroup_sandbox_python() { sandbox_forget_configs } +testcase_sandbox_parseconf() { + if ! isTestablePython || [ -z "${PYTHON}" ] \ + || ! $PYTHON -c 'import json' >/dev/null 2>&1 ; then + case "${NIT_CASE}" in + testcase_sandbox_parseconf|testgroup_sandbox_parseconf) + log_error "[testcase_sandbox_parseconf] Python with the json module is required" + FAILED="`expr $FAILED + 1`" + FAILED_FUNCS="$FAILED_FUNCS testcase_sandbox_parseconf:missing-prerequisites" + ;; + *) + log_warn "[testcase_sandbox_parseconf] SKIPPED: Python with the json module is unavailable" + SKIPPED="`expr $SKIPPED + 1`" + SKIPPED_FUNCS="$SKIPPED_FUNCS testcase_sandbox_parseconf" + ;; + esac + return + fi + + log_separator + log_info "[testcase_sandbox_parseconf] Check exact text and decoded JSON values with $PYTHON" + if ( + # Match execcmd's per-client debug setting without changing later tests. + if [ -n "${NUT_DEBUG_LEVEL_UPSC-}" ]; then + NUT_DEBUG_LEVEL="${NUT_DEBUG_LEVEL_UPSC}" + export NUT_DEBUG_LEVEL + fi + $PYTHON "${TOP_SRCDIR}/tests/NIT/parseconf-test.py" \ + "${TOP_BUILDDIR}/clients/upsc${EXEEXT}" "$NUT_PORT" + ) ; then + PASSED="`expr $PASSED + 1`" + log_info "[testcase_sandbox_parseconf] PASSED: exact text and decoded JSON values" + else + log_error "[testcase_sandbox_parseconf] FAILED: parser checks complained, check above" + FAILED="`expr $FAILED + 1`" + FAILED_FUNCS="$FAILED_FUNCS testcase_sandbox_parseconf" + fi +} + +testgroup_sandbox_parseconf() { + # Start a fresh sandbox so these punctuation fixtures do not change + # the model, timer and language-binding tests' expected data. + if ! isTestablePython || [ -z "${PYTHON}" ] \ + || ! $PYTHON -c 'import json' >/dev/null 2>&1 ; then + testcase_sandbox_parseconf + return + fi + + stop_daemons + sandbox_forget_configs + sandbox_generate_configs + $PYTHON "${TOP_SRCDIR}/tests/NIT/parseconf-test.py" prepare "$NUT_CONFPATH" \ + || die "[testgroup_sandbox_parseconf] Could not prepare parser fixtures" + testcase_sandbox_start_drivers_after_upsd + testcase_sandbox_parseconf + log_separator + sandbox_forget_configs +} + testgroup_sandbox_perl() { # Arrange for quick test iterations testcase_sandbox_start_drivers_after_upsd @@ -4885,6 +4944,7 @@ case "${NIT_CASE}" in testgroup_upsd_invalid_configs testgroup_upsd_questionable_configs testgroup_sandbox + testgroup_sandbox_parseconf ;; *) die "Unsupported NIT_CASE='$NIT_CASE' was requested" ;; esac diff --git a/tests/NIT/parseconf-test.py b/tests/NIT/parseconf-test.py new file mode 100644 index 0000000000..50057c5601 --- /dev/null +++ b/tests/NIT/parseconf-test.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# Exact values through configuration, dummy-ups, upsd and upsc. +# nit.sh selects $PYTHON; keep this script compatible with Python 2.6+ and 3. +# Copyright (C) 2026 Network UPS Tools project +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import print_function + +import json +import math +import os +import subprocess +import sys +import threading + + +# Input is NUT syntax; expected values are literal decoded strings. +VALUES = [ + ('Outlet #1', 'Outlet'), + ('"Outlet \\#2"', 'Outlet #2'), + ('"Outlet #3"', 'Outlet #3'), + ('Outlet \\#4', 'Outlet #4'), + ('"\\"edge\\" C:\\\\ups #5"', '"edge" C:\\ups #5'), + ('one\\ two \\n \\t \\x41', 'one two n t x41'), + ("'single quotes'", "'single quotes'"), + ('"a=b:c two spaces"', 'a=b:c two spaces'), + ('prefix\\\nsuffix', 'prefixsuffix'), + ('""', None), # dummy-ups deletes variables assigned an empty value. + ('"a\\\\#b"', 'a\\#b'), + ('"a\\\\\\"b"', 'a\\"b'), + ('Outlet\\ \\#4', 'Outlet #4'), +] +DESCRIPTION_INPUT = '"Parser #1: \\"quoted\\" C:\\\\ups\'s"' +DESCRIPTION = 'Parser #1: "quoted" C:\\ups\'s' + + +def prepare(confpath): + path = os.path.join(confpath, 'ups.conf') + with open(path, 'r') as source: + config = source.read() + original = 'desc = "Crash Dummy"' + if config.count(original) != 1: + raise AssertionError('expected one fresh NIT dummy configuration') + with open(path, 'w') as target: + target.write(config.replace(original, 'desc = ' + DESCRIPTION_INPUT)) + with open(os.path.join(confpath, 'dummy.seq'), 'w') as target: + target.write('ups.status: OL\n') + for index, (value, expected) in enumerate(VALUES): + if expected is None: + target.write('outlet.%d.desc: before empty\n' % (index + 1)) + target.write('outlet.%d.desc: %s\n' % (index + 1, value)) + target.write('TIMER 60\n') + + +def query(upsc, *args): + timeout = float(os.environ.get('NIT_PARSECONF_TIMEOUT', '60')) + if timeout <= 0 or math.isnan(timeout) or math.isinf(timeout): + raise ValueError('NIT_PARSECONF_TIMEOUT must be positive finite seconds') + command = [upsc] + list(args) + print('START: %r (watchdog %g seconds)' % (command, timeout)) + sys.stdout.flush() + process = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + expired = threading.Event() + + def expire(): + if process.poll() is None: + try: + process.kill() + except OSError: + # The child may have exited between poll() and kill(). + if process.poll() is None: + raise + else: + expired.set() + + # Python 2.6 also runs NIT; communicate(timeout=...) is newer. + timer = threading.Timer(timeout, expire) + timer.start() + try: + output, error = process.communicate() + finally: + timer.cancel() + timer.join() + if expired.is_set(): + raise AssertionError('%r timed out after %g seconds; watchdog killed ' + 'the process: stdout=%r stderr=%r' % + (command, timeout, output, error)) + if process.returncode: + raise AssertionError('%r returned %d: stdout=%r stderr=%r' % + (command, process.returncode, output, error)) + # Fixtures are ASCII. Normalize only the platform's output line ending. + return output.decode('ascii').replace('\r\n', '\n') + + +def equal(actual, expected): + if actual != expected: + raise AssertionError('got %r, expected %r' % (actual, expected)) + + +def self_test(): + original = os.environ.get('NIT_PARSECONF_TIMEOUT') + child = ('import sys, time; sys.stdout.write("output\\n"); ' + 'sys.stdout.flush(); sys.stderr.write("diagnostic\\n"); ' + 'sys.stderr.flush(); ') + try: + os.environ['NIT_PARSECONF_TIMEOUT'] = '5' + equal(query(sys.executable, '-c', child + 'time.sleep(2)'), 'output\n') + for action, timeout, message in [('sys.exit(7)', '5', 'returned 7'), + ('time.sleep(2)', '1', 'timed out after 1 seconds')]: + os.environ['NIT_PARSECONF_TIMEOUT'] = timeout + try: + query(sys.executable, '-c', child + action) + except AssertionError as error: + text = str(error) + if (message not in text + or 'stdout=%r' % b'output\n' not in text + or 'stderr=%r' % b'diagnostic\n' not in text): + raise + else: + raise AssertionError('expected query failure: ' + message) + for timeout in ['0', '-1', 'nan', 'inf']: + os.environ['NIT_PARSECONF_TIMEOUT'] = timeout + try: + query(sys.executable, '-c', 'pass') + except ValueError: + pass + else: + raise AssertionError('accepted invalid watchdog: ' + timeout) + finally: + if original is None: + del os.environ['NIT_PARSECONF_TIMEOUT'] + else: + os.environ['NIT_PARSECONF_TIMEOUT'] = original + print('PASS: query output, process failure, watchdog and timeout validation') + + +def check(upsc, port): + host = '127.0.0.1:' + port + ups = 'dummy@' + host + plain = query(upsc, ups).splitlines() + data = json.loads(query(upsc, '-j', ups)) + for index, (value, expected) in enumerate(VALUES): + key = 'outlet.%d.desc' % (index + 1) + if expected is None: + equal([line for line in plain if line.startswith(key + ':')], []) + equal(key in data, False) + print('PASS: empty value removes %s from text and JSON lists' % key) + continue + equal([line for line in plain if line.startswith(key + ':')], + [key + ': ' + expected]) + equal(data[key], expected) + equal(query(upsc, ups, key), expected + '\n') + equal(json.loads(query(upsc, '-j', ups, key)), expected) + print('PASS: %s = %r (text and JSON, list and single value)' % + (key, expected)) + + names = query(upsc, '-l', host).splitlines() + equal(json.loads(query(upsc, '-j', '-l', host)), names) + descriptions = json.loads(query(upsc, '-j', '-L', host)) + equal(sorted(descriptions), sorted(names)) + equal(descriptions['dummy'], DESCRIPTION) + equal([line for line in query(upsc, '-L', host).splitlines() + if line.startswith('dummy:')], ['dummy: ' + DESCRIPTION]) + # These read-only clients do not LOGIN to the UPS. + equal(query(upsc, '-c', ups).splitlines(), []) + equal(json.loads(query(upsc, '-j', '-c', ups)), []) + print('PASS: configured description, UPS lists and empty client lists') + + +if __name__ == '__main__': + if sys.argv[1] == 'self-test': + self_test() + elif sys.argv[1] == 'prepare': + prepare(sys.argv[2]) + else: + check(sys.argv[1], sys.argv[2]) diff --git a/tests/parseconf-test.c b/tests/parseconf-test.c new file mode 100644 index 0000000000..0dc170f80a --- /dev/null +++ b/tests/parseconf-test.c @@ -0,0 +1,229 @@ +/* parseconf-test - configuration and protocol token boundaries + + Copyright (C) 2026 Network UPS Tools project + + 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 + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ + +#include "config.h" /* should be first */ +#include "common.h" +#include "parseconf.h" + +typedef struct { + const char *name; + const char *input; + size_t numargs; + const char *args[8]; +} testcase_t; + +static const testcase_t cases[] = { + { "ordinary", "one two\n", 2, { "one", "two" } }, + { "empty", " \t\n", 0, { NULL } }, + { "comment", "# ignored\n", 0, { NULL } }, + { "unquoted hash", "Outlet #1\n", 1, { "Outlet" } }, + { "attached comment", "my#1ups\n", 1, { "my" } }, + { "quoted hash", "\"Outlet #3\"\n", 1, { "Outlet #3" } }, + { "quoted hash boundaries", "\"#\" \"##last#\"\n", 2, { "#", "##last#" } }, + { "escaped hash", "\"Outlet \\#2\" Outlet \\#4\n", 3, + { "Outlet #2", "Outlet", "#4" } }, + { "comment after quote", "\"# value\"# ignored\n", 1, { "# value" } }, + { "empty quotes", "\"\" \"\"\n", 2, { "", "" } }, + { "closing quote commits", "\"one\"two \"three\"\"four\"\n", 4, + { "one", "two", "three", "four" } }, + { "quote inside unquoted word", "a\"b\"c\n", 1, { "a\"b\"c" } }, + { "apostrophes", "it's 'two words' \"'literal'\"\n", 4, + { "it's", "'two", "words'", "'literal'" } }, + { "equal separators", "a=b==\"c=d\"\n", 6, { "a", "=", "b", "=", "=", "c=d" } }, + { "escaped spaces and equal", "one\\ two \\=value \"a\\ b\\=c\"\n", 3, + { "one two", "=value", "a b=c" } }, + { "escaped letters", "\\n\\t\\r\\x41 \"\\n\\t\\r\\x41\"\n", 2, + { "ntrx41", "ntrx41" } }, + { "escaped quotes", "\\\"edge\\\" \"\\\"edge\\\"\"\n", 2, + { "\"edge\"", "\"edge\"" } }, + { "escaped backslashes", "a\\\\b \"a\\\\b\\\\\\\\c\"\n", 2, + { "a\\b", "a\\b\\\\c" } }, + { "backslash before hash", "\"a\\\\#b\" \"a\\\\\\#b\"\n", 2, + { "a\\#b", "a\\#b" } }, + { "backslash before quote", "\"a\\\\\"b \"a\\\\\\\"b\"\n", 3, + { "a\\", "b", "a\\\"b" } }, + { "continuation", "one\\\ntwo \"three\\\nfour\"\n", 2, + { "onetwo", "threefour" } }, + { "quoted physical newline", "\"one\ntwo\"\n", 1, { "onetwo" } }, + { "character filter", "\"a\001\t\r\200\377b\177\"\n", 1, { "ab\177" } } +}; + +static int failures = 0; + +static void check_args(PCONF_CTX_t *ctx, const testcase_t *test, const char *mode) +{ + size_t i; + + if (pconf_parse_error(ctx)) { + fprintf(stderr, "%s/%s: unexpected parse error: %s\n", + test->name, mode, ctx->errmsg); + failures++; + } + if (ctx->numargs != test->numargs) { + fprintf(stderr, "%s/%s: got %" PRIuSIZE " arguments, expected %" PRIuSIZE "\n", + test->name, mode, ctx->numargs, test->numargs); + failures++; + return; + } + for (i = 0; i < test->numargs; i++) { + if (strcmp(ctx->arglist[i], test->args[i])) { + fprintf(stderr, "%s/%s: argument %" PRIuSIZE ": got <%s>, expected <%s>\n", + test->name, mode, i, ctx->arglist[i], test->args[i]); + failures++; + } + } +} + +static void check_result(int actual, int expected, const char *name) +{ + if (actual != expected) { + fprintf(stderr, "%s: got %d, expected %d\n", name, actual, expected); + failures++; + } +} + +static void file_input(PCONF_CTX_t *ctx, const char *input) +{ + /* tmpfile() gives each run a private stream and removes it on close. */ + ctx->f = tmpfile(); + if (!ctx->f) + fatal_with_errno(EXIT_FAILURE, "tmpfile"); + if (fputs(input, ctx->f) == EOF || fflush(ctx->f)) + fatal_with_errno(EXIT_FAILURE, "write test input"); + rewind(ctx->f); +} + +static void check_case(const testcase_t *test) +{ + PCONF_CTX_t ctx; + size_t i, len = strlen(test->input); + int ret; + + pconf_init(&ctx, NULL); + check_result(pconf_line(&ctx, test->input), 1, test->name); + check_args(&ctx, test, "line"); + pconf_finish(&ctx); + + pconf_init(&ctx, NULL); + file_input(&ctx, test->input); + check_result(pconf_file_next(&ctx), 1, test->name); + check_args(&ctx, test, "file"); + check_result(pconf_file_next(&ctx), 0, "file EOF"); + pconf_finish(&ctx); + + pconf_init(&ctx, NULL); + for (i = 0; i < len; i++) { + ret = pconf_char(&ctx, test->input[i]); + check_result(ret, (i == len - 1) ? 1 : 0, test->name); + if (ret < 0) + break; + } + check_args(&ctx, test, "char"); + pconf_finish(&ctx); +} + +static void check_boundaries(void) +{ + PCONF_CTX_t ctx; + static const testcase_t incomplete = { + "incomplete quote", "key \"unfinished", 2, { "key", "unfinished" } + }; + static const testcase_t limited = { + "limits", "abcde fghij discarded\n", 2, { "abcd", "fghi" } + }; + static const testcase_t trailing = { + "trailing backslash", "key value\\", 2, { "key", "value" } + }; + static const testcase_t next = { + "next line", "next\n", 1, { "next" } + }; + size_t i; + + /* These incomplete inputs historically return their collected words. */ + pconf_init(&ctx, NULL); + check_result(pconf_line(&ctx, incomplete.input), 1, incomplete.name); + check_args(&ctx, &incomplete, "line"); + check_result(pconf_line(&ctx, trailing.input), 1, trailing.name); + check_args(&ctx, &trailing, "line"); + ctx.arg_limit = 2; + ctx.wordlen_limit = 4; + check_result(pconf_line(&ctx, limited.input), 1, limited.name); + check_args(&ctx, &limited, "line"); + pconf_finish(&ctx); + + pconf_init(&ctx, NULL); + file_input(&ctx, incomplete.input); + check_result(pconf_file_next(&ctx), 1, incomplete.name); + check_args(&ctx, &incomplete, "file"); + check_result(pconf_file_next(&ctx), 0, "incomplete file EOF"); + pconf_finish(&ctx); + + pconf_init(&ctx, NULL); + for (i = 0; i < strlen(incomplete.input); i++) + check_result(pconf_char(&ctx, incomplete.input[i]), 0, "incomplete char input"); + check_result(pconf_parse_error(&ctx), 0, "incomplete char error"); + pconf_finish(&ctx); + + pconf_init(&ctx, NULL); + for (i = 0; i < 2; i++) { + check_result(pconf_char(&ctx, '"'), 0, "empty token start"); + check_result(pconf_char(&ctx, '"'), 0, "empty token end"); + check_result(pconf_char(&ctx, '\n'), 1, "char line end"); + check_result(pconf_line(&ctx, next.input), 1, "next line"); + check_args(&ctx, &next, "reused context"); + } + pconf_finish(&ctx); + check_result(pconf_line(&ctx, "ignored"), 0, "finished context"); + check_result(pconf_line(NULL, "ignored"), 0, "NULL context"); + check_result(pconf_char(NULL, 'x'), -1, "NULL char context"); +} + +static void check_encoder(void) +{ + static const char raw[] = "# \\\"' = end#"; + static const char encoded[] = "\\# \\\\\\\"' = end\\#"; + PCONF_CTX_t ctx; + char output[128], input[132]; + const testcase_t roundtrip = { "encoder roundtrip", NULL, 1, { raw } }; + const testcase_t short_result = { "short encoder roundtrip", NULL, 1, { "#" } }; + + check_result(pconf_encode(raw, output, sizeof(output)) == output, 1, "encoder return"); + check_result(strcmp(output, encoded), 0, "encoder bytes (including escaped hash)"); + snprintf(input, sizeof(input), "\"%s\"\n", output); + pconf_init(&ctx, NULL); + check_result(pconf_line(&ctx, input), 1, "encoder parse"); + check_args(&ctx, &roundtrip, "line"); + + /* A truncated encoding must not leave an unmatched escape character. */ + pconf_encode("##", output, 4); + check_result(strcmp(output, "\\#"), 0, "short encoder bytes"); + snprintf(input, sizeof(input), "\"%s\"\n", output); + check_result(pconf_line(&ctx, input), 1, "short encoder parse"); + check_args(&ctx, &short_result, "line"); + pconf_encode("#", output, 2); + check_result(output[0], '\0', "encoder cannot fit escape pair"); + output[0] = 'x'; + pconf_encode("#", output, 0); + check_result(output[0], 'x', "zero sized encoder destination"); + pconf_finish(&ctx); +} + +int main(void) +{ + size_t i; + + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) + check_case(&cases[i]); + check_boundaries(); + check_encoder(); + printf("parseconf: %" PRIuSIZE " cases through line/file/char, boundary and encoder checks: %d failures\n", + sizeof(cases) / sizeof(cases[0]), failures); + return failures ? EXIT_FAILURE : EXIT_SUCCESS; +}