Repository: networkupstools/nut
Pull request #2963: common.c: VA copies with buffer upsizing, upsdrvquery.c: str handling fixes
Author: login: ghost
Labels: name: bug
name: enhancement
name: refactor/fightwarn
description: PR or issue proposal to improve code maintainability without functional changes, or to fix warnings
name: impacts-release-2.8.2
description: Issues reported against NUT release 2.8.2 (maybe vanilla or with minor packaging tweaks)
name: C-str
description: Issues and PRs about C/C++ methods, headers and data types dealing with strings and memory blocks
name: impacts-release-2.8.3
description: Issues reported against NUT release 2.8.3 (maybe vanilla or with minor packaging tweaks)
Created: 2025-05-20T07:10:00Z | Merged: 2025-05-27T22:23:09Z

I took a preliminary shot at this, feel free to change or move around as required.

If upsizing log buffers we will most probably need copying of variadic arguments, as outlined here:
https://github.com/networkupstools/nut/issues/2948#issuecomment-2869130856

Some systems have `va_copy`, others `__va_copy`, others nothing - this should account for all eventualities.
In case absolutely no variadic argument copying is supported, truncated logs are shown and buffers not upsized.

The PR also addresses the needed fixes in `drivers/upsdrvquery.c` to account for non-null-terminated output.

fixes #2948 

Linked issue #2948: SIGSEGV in drivers/upsdrvquery.c: upsdrvquery_read_timeout()
Hello!

I hit a segmentation fault in `drivers/upsdrvquery.c` during driver development today, when in debug level 6.
Note that the displayed `errno` is related to a previous unrelated failure - simulating a dead driver socket for failover.

```
   0.004037     [D5] upsdrvquery_read_timeout: tv={sec=3, usec=000000}
WARNING: vupslog: vsnprintf needed more than 1024 bytes: 1108 (2 => No such file or directory), extending to 2132

Program received signal SIGSEGV, Segmentation fault.
0x0000155555472b59 in __strlen_avx2 () from /lib64/libc.so.6
(gdb) bt
#0  0x0000155555472b59 in __strlen_avx2 () from /lib64/libc.so.6
#1  0x000015555535da40 in __printf_buffer () from /lib64/libc.so.6
#2  0x000015555538342d in vsnprintf () from /lib64/libc.so.6
#3  0x00000000004114d1 in vupslog ()
#4  0x0000000000411985 in s_upsdebugx ()
#5  0x000000000040dddb in upsdrvquery_read_timeout ()
#6  0x0000000000405b73 in upsdrv_updateinfo ()
#7  0x0000000000404847 in main ()
(gdb) 
```

https://github.com/networkupstools/nut/blob/093e44b1c8db556f356263951f906db3551d5f3e/drivers/upsdrvquery.c#L366-L371

I don't have time to look into this more at the moment, thought I'd log it not to forget.

Possibly related to the SIGSEGV in the other PR #2947.
Linked issue comments: user: login: jimklimov
created_at: 2025-05-10T16:49:45Z
body: Thanks! I suppose a large print like that is possible in the `upsdebug_hex()` line, but according to the message `vupslog()` should try a larger buffer. Perhaps that part fails, and then the bug probably has a long history in NUT code base, which would be worrysome (but at least not something that is easy to exploit as a security breach on the tons of NUT installations, since debug logging is usually disabled unless needed for a troubleshooting session).
author_association: MEMBER
user: login: ghost
created_at: 2025-05-10T16:52:50Z
body: Could it be that the read buffer is not null terminated and the log function tries to read further until it finds a null termination, in expectation of a null terminated string at %s? I don't think the hex dumps were anywhere near exceeding buffer capacity, fwiw.

Generally defaulting to printing only? the hex dump seemed more practical to me, regardless of the null termination issue or not.
author_association: NONE
user: login: ghost
created_at: 2025-05-10T19:14:23Z
body: Back with some more information from debug symbols...
```
#0  0x0000155555472b59 in __strlen_avx2 () from /lib64/libc.so.6
#1  0x000015555535da40 in __printf_buffer () from /lib64/libc.so.6
#2  0x000015555538342d in vsnprintf () from /lib64/libc.so.6
#3  0x000000000041789a in vupslog (priority=7, fmt=0x7fffffffd5b0 "[D5] %s: received %li bytes from driver socket: %s", va=0x7fffffffd9b0, 
    use_strerror=0) at common.c:3209
#4  0x00000000004182d2 in s_upsdebugx (level=5, fmt=0x7fffffffd5b0 "[D5] %s: received %li bytes from driver socket: %s") at common.c:3669
#5  0x0000000000410c18 in upsdrvquery_read_timeout (conn=0x44e8d0, tv=...) at upsdrvquery.c:366
#6  0x00000000004051c0 in ups_read_data (ups=0x4352b0) at failover.c:692
#7  0x000000000040401c in update_connections () at failover.c:359
#8  0x0000000000403853 in upsdrv_updateinfo () at failover.c:139
#9  0x000000000040b025 in main (argc=0, argv=0x7fffffffe630) at main.c:2859
```

```
(gdb) frame 5
#5  0x0000000000410c18 in upsdrvquery_read_timeout (conn=0x44e8d0, tv=...) at upsdrvquery.c:366
warning: 366    upsdrvquery.c: No such file or directory
(gdb) info args
conn = 0x44e8d0
tv = {tv_sec = 2, tv_usec = 726447}
(gdb) info locals
ret = 973
rfds = {fds_bits = {32, 0 <repeats 15 times>}}
__func__ = "upsdrvquery_read_timeout"
(gdb) print conn->buf
$1 = "SETINFO battery.capacity \"9.00\"\nSETAUX battery.capacity 32\nSETFLAGS battery.capacity RW STRING\nSETINFO battery.charge \"100\"\nSETFLAGS battery.charge RW\nSETINFO battery.charge.low \"20\"\nSETFLAGS battery."...
(gdb) print conn
$2 = (udq_pipe_conn_t *) 0x44e8d0
(gdb) print ret
$3 = 973
(gdb) frame 4
#4  0x00000000004182d2 in s_upsdebugx (level=5, fmt=0x7fffffffd5b0 "[D5] %s: received %li bytes from driver socket: %s") at common.c:3669
warning: 3669   common.c: No such file or directory
(gdb) info locals
va = {{gp_offset = 48, fp_offset = 48, overflow_arg_area = 0x7fffffffdaa0, reg_save_area = 0x7fffffffd9d0}}
fmt2 = "[D5] %s: received %li bytes from driver socket: %s\000\377\377\177\000\000\367\326\377\377\377\177\000\000\320\032B\000\000\000\000\000\020SUUU\025\000\000.\000\000\000\000\000\000\000\250\034@\000\000\000\000\000p\221B\000\000\000\000\000\300\215B\000\000\000\000\000\000@UUU\025\000\000\243\300RUU\025\000\000\001", '\000' <repeats 15 times>, "\330I0UU\025\000\000\240\251@UU\025\000\000p\332\377\377\377\177\000\000P\333\377\377\377\177\000\000\253\350\377\377\377\177\000\0008\346\377\377\377\177\000\000\300\215B\000\000\000\000\000"...
NUT_DEBUG_PID = 0
(gdb) info args
level = 5
fmt = 0x7fffffffd5b0 "[D5] %s: received %li bytes from driver socket: %s"
(gdb) frame 3
#3  0x000000000041789a in vupslog (priority=7, fmt=0x7fffffffd5b0 "[D5] %s: received %li bytes from driver socket: %s", va=0x7fffffffd9b0, use_strerror=0) at common.c:3209
3209    in common.c
(gdb) info locals
ret = 1043
errno_orig = 3
bufsize = 2067
buf = 0x44f670 "[D5] (null): received 2 bytes from driver socket: from driver socket: SETINFO battery.capacity \"9.00\"\nSETAUX battery.capacity 32\nSETFLAGS battery.capacity RW STRING\nSETINFO battery.charge \"100\"\nSETFLA"...
(gdb) info args
priority = 7
fmt = 0x7fffffffd5b0 "[D5] %s: received %li bytes from driver socket: %s"
va = 0x7fffffffd9b0
use_strerror = 0
(gdb) frame 2
#2  0x000015555538342d in vsnprintf () from /lib64/libc.so.6
(gdb) info locals
No symbol table info available.
(gdb) info args
No symbol table info available.
(gdb) frame 1
#1  0x000015555535da40 in __printf_buffer () from /lib64/libc.so.6
(gdb) info locals
No symbol table info available.
(gdb) info args
No symbol table info available.
(gdb) 
#0  0x0000155555472b59 in __strlen_avx2 () from /lib64/libc.so.6
(gdb) info locals
No symbol table info available.
(gdb) info args
No symbol table info available.
(gdb) 
```
author_association: NONE
user: login: ghost
created_at: 2025-05-10T19:51:49Z
body: I think I've pinpointed the issue, will do some more testing tomorrow.
Seems related to reuse of variadic arguments within `common.c`'s `vupslog()` on buffer resizing attempts.
That would also explain why it's not appeared more commonly, as buffer resize should be a rarer occurrence.

Quick patch seems to resolve the issue and not mangle the variadic arguments:
```
diff --git a/common/common.c b/common/common.c
index b3453f909..38644d108 100644
--- a/common/common.c
+++ b/common/common.c
@@ -3181,6 +3181,7 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	int	ret, errno_orig = errno;
 	size_t	bufsize = LARGEBUF;
 	char	*buf = xcalloc(bufsize, sizeof(char));
+	va_list va_snprintf;
 
 	/* Be pedantic about our limitations */
 	bufsize *= sizeof(char);
@@ -3206,7 +3207,9 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	 * or the calling methods should check it against their
 	 * "fmt_dynamic" expectations). */
 	do {
-		ret = vsnprintf(buf, bufsize, fmt, va);
+		va_copy(va_snprintf, va);
+		ret = vsnprintf(buf, bufsize, fmt, va_snprintf);
+		va_end(va_snprintf);
 
 		if ((ret < 0) || ((uintmax_t)ret >= (uintmax_t)bufsize)) {
 			/* Try to adjust bufsize until we can print the
````

Unrelated after all, but as for `upsdrvquery.c`: Is `upsdrvquery_read_timeout()` on purpose not producing a null-terminated `char buf[LARGEBUF]` for `conn->buf`? Should we read `sizeof(conn->buf) - 1` and null-terminate this or would this break API expectations?
author_association: NONE
user: login: jimklimov
created_at: 2025-05-10T19:53:11Z
body: Hm, gotta look into code (getting back to a computer now), but OTOH your analysis and idea about zero-byte involvement might point in right direction: the memory-oriented `upsdebug_hex()` can be printing out bytes (including zero ones) from the specified length of the buffer; but the string-oriented `vupslog()` extending the buffer *might* use `strlen()` or `snprintf()` and so allocate just until the first zero-byte, and copy that much. And then the size-oriented printer looks beyond the reallocated block => segfault.

At least this hypothesis sounds logical, gotta take a look...

UPDATE: As I was typing this answer, you posted a continuation. So at least the guess about `snprintf()` involvement seems right.
author_association: MEMBER
user: login: jimklimov
created_at: 2025-05-10T20:03:32Z
body: > Unrelated after all, but as for upsdrvquery.c: Is upsdrvquery_read_timeout() on purpose not producing a null-terminated char buf[LARGEBUF] for conn->buf? Should we read sizeof(conn->buf) - 1 and null-terminate this or would this break API expectations?

Good question. Looking at https://github.com/networkupstools/nut/blob/master/drivers/upsdrvquery.c#L268-L269 we do `memset` the whole buffer to zeroes, and then `read()` up to its size. IIRC this is same approach as for other potentially looping reads everywhere (and not only NUT), e.g. when too much data is incoming, the caller loop somehow processes the whole buffer until the read returns less than its size. So I guess nul-terminating the content would be an API break.
author_association: MEMBER
user: login: jimklimov
created_at: 2025-05-10T20:12:58Z
body: And thanks for the detailed GDB trace analysis - at the moment of failure, it does not get to `upsdebug_hex()` at all actually (makes sense given you see this only at debug level 6 and this is mentioned only in the preceding operation.

Now circling back to your initial version of the post, where you mentioned `%.*s` formatting. This might actually be an answer here, especially for logging the reads which do fill the `conn->buf` so that its last byte is not `'\0'` (and assuming no zero-bytes along the way either): at least the logged text (or other bytes) would be limited by buffer size - something that I now think could bite us even if buffer resizing were not involved.
author_association: MEMBER
user: login: ghost
created_at: 2025-05-10T20:24:43Z
body: > And thanks for the detailed GDB trace analysis - at the moment of failure, it does not get to `upsdebug_hex()` at all actually (makes sense given you see this only at debug level 6 and this is mentioned only in the preceding operation.
> 
> Now circling back to your initial version of the post, where you mentioned `%.*s` formatting. This might actually be an answer here, especially for logging the reads which do fill the `conn->buf` so that its last byte is not `'\0'` (and assuming no zero-bytes along the way either): at least the logged text (or other bytes) would be limited by buffer size - something that I now think could bite us even if buffer resizing were not involved.

`%.*s` definitely avoids the undefined behavior of `%s`, but it feels like a bit of a partial fit here. Given what you said about deliberately not null-terminating the buffer and treating it as a potentially full, protocol-agnostic continuous stream, maybe just dumping hex would better match the overall expectations of the function — rather than printing potentially partial or garbled messages? That design makes perfect sense from that perspective, by the way — thanks for the clarification.

At present, the hex dump itself seems to only trigger in some circumstances:
https://github.com/networkupstools/nut/blob/093e44b1c8db556f356263951f906db3551d5f3e/drivers/upsdrvquery.c#L369-L371
author_association: NONE
user: login: ghost
created_at: 2025-05-10T20:38:00Z
body: ... or dump the hex at 5 and possibly mangled, partial with `%.*s` at 6 for the best of both worlds?
author_association: NONE
user: login: jimklimov
created_at: 2025-05-10T20:46:55Z
body: A reminder while hacking around `%.*s`:
> It should be noted that the `str_len` argument must have type `int` (or narrower integral type, which would be promoted to `int`).
> It would be a bug to pass `long`, `size_t`, etc. 

and

> If the value of this argument is negative, it is ignored.

So there's something for checking the `ret` value pedantically (also that it does not exceed signed `INT_MAX`).
author_association: MEMBER
user: login: ghost
created_at: 2025-05-10T20:49:59Z
body: I think there's also some other cases throughout the file where `conn->buf` is used in a manner that would expect a null-terminated string, if I remember correctly (not near a computer myself now), might also need to brush these up with `%.*s` or similar.
author_association: NONE
user: login: jimklimov
created_at: 2025-05-10T20:54:29Z
body: > ...it feels like a bit of a partial fit here. Given what you said about deliberately not null-terminating the buffer and treating it as a potentially full...

I think now that it is actually a good fit for this potential issue: if we get a kilobyte's worth of all non-zero characters, in the context of NUT driver-server chatter, we are likely to be looking at a big block of text, and it would be useful for troubleshooting to print it as such (or technically the first non-zero `sizeof(conn->buf)` bytes from memory pointed to by `conn->buf` and then having `*printf()` family of methods stop looking at that memory range).

This may be different for other use-cases of hex dump (e.g. USB HID report descriptors or other binary protocol data), and this particular issue is not even in the hex dump, so I guess this is my bug as a careless caller of the logging method :)
author_association: MEMBER
user: login: ghost
created_at: 2025-05-10T20:58:25Z
body: > > ...it feels like a bit of a partial fit here. Given what you said about deliberately not null-terminating the buffer and treating it as a potentially full...
> 
> I think now that it is actually a good fit for this potential issue: if we get a kilobyte's worth of all non-zero characters, in the context of NUT driver-server chatter, we are likely to be looking at a big block of text, and it would be useful for troubleshooting to print it as such (or technically the first non-zero `sizeof(conn->buf)` bytes from memory pointed to by `conn->buf` and then having `*printf()` family of methods stop looking at that memory range).
> 
> This may be different for other use-cases of hex dump (e.g. USB HID report descriptors or other binary protocol data), and this particular issue is not even in the hex dump, so I guess this is my bug as a careless caller of the logging method :)

If you put it like that... 😉 No, of course, that makes sense too, thanks!
author_association: NONE

Diff:
diff --git a/NEWS.adoc b/NEWS.adoc
index 48c2812afa..c45ea316c7 100644
--- a/NEWS.adoc
+++ b/NEWS.adoc
@@ -59,6 +59,13 @@ https://github.com/networkupstools/nut/milestone/9
      `DRIVERLIST` variables inspected by `configure` script, rather than
      having all their names hard-coded like before, led to inability to
      `configure --with-drivers=dummy-ups`. [#2825, #2927, fixed by PR #2929]
+   * A problem noted with `upsdrvquery` (since NUT v2.8.1) message logging
+     at high debug verbosity levels (5+) with very large blocks of content
+     has exposed a deficiency in variable-argument handling, and specifically
+     adaptive resizing of the output buffer or truncation of logged inputs
+     (which is something NUT code tried to do since the beginning of time),
+     and could lead to "segmentation fault" crashes on some platforms.
+     [issue #2948, PR #2963]
 
  - common code:
    * Revised common `writepid()` to use `altpidpath()` as location for the
diff --git a/common/common.c b/common/common.c
index b3453f9090..d6a9dfd3d8 100644
--- a/common/common.c
+++ b/common/common.c
@@ -3206,9 +3206,25 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	 * or the calling methods should check it against their
 	 * "fmt_dynamic" expectations). */
 	do {
+#ifdef HAVE_VA_COPY_VARIANT
+		va_list	va_snprintf;
+
+		/* va_copy() to avoid mangling on re-use (see issue #2948),
+		 * this lets us retry safely vsnprintf() with the VA copies */
+		va_copy(va_snprintf, va);
+		ret = vsnprintf(buf, bufsize, fmt, va_snprintf);
+		va_end(va_snprintf);
+#else
+		/* Without va_copy(), we cannot safely retry vsnprintf()
+		 * and will accept truncation if the buffer is too small */
 		ret = vsnprintf(buf, bufsize, fmt, va);
+#endif
 
 		if ((ret < 0) || ((uintmax_t)ret >= (uintmax_t)bufsize)) {
+			/* Not building the below block on systems without va_copy(),
+			 * otherwise consumed VAs would get re-used & mangled on retries.
+			 * Instead, we want fall-through directly to the truncation message. */
+#ifdef HAVE_VA_COPY_VARIANT
 			/* Try to adjust bufsize until we can print the
 			 * whole message. Note that standards only require
 			 * up to 4095 bytes to be manageable in printf-like
@@ -3261,6 +3277,7 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 		/* Arbitrary limit, gotta stop somewhere */
 		if (bufsize > LARGEBUF * 64) {
 vupslog_too_long:
+#endif
 			if (syslog_is_disabled()) {
 				fprintf(stderr, "vupslog: vsnprintf needed "
 					"more than %" PRIuSIZE " bytes; logged "
diff --git a/configure.ac b/configure.ac
index cbe9eee67d..6b454f3023 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1433,6 +1433,54 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    AX_RUN_OR_LINK_IFELSE(
+      [AC_LANG_PROGRAM(
+        [[#include <stdarg.h>
+          void check_va_copy(const char *fmt, ...) {
+            va_list ap1, ap2;
+            va_start(ap1, fmt);
+            va_copy(ap2, ap1);
+            va_end(ap1);
+            va_end(ap2);
+          }]],
+        [[check_va_copy("%s", "test");]])],
+      [ac_cv_have_va_copy=yes],
+      [ac_cv_have_va_copy=no])
+  ])
+
+AS_IF([test x"$ac_cv_have_va_copy" = xyes],
+  [AC_DEFINE([HAVE_VA_COPY], [1], [defined if va_copy(dest,src) is available])])
+
+AC_CACHE_CHECK([for __va_copy(dest,src)],
+  [ac_cv_have___va_copy],
+  [
+    AX_RUN_OR_LINK_IFELSE(
+      [AC_LANG_PROGRAM(
+        [[#include <stdarg.h>
+          void check___va_copy(const char *fmt, ...) {
+            va_list ap1, ap2;
+            va_start(ap1, fmt);
+            __va_copy(ap2, ap1);
+            va_end(ap1);
+            va_end(ap2);
+          }]],
+        [[check___va_copy("%s", "test");]])],
+      [ac_cv_have___va_copy=yes],
+      [ac_cv_have___va_copy=no])
+  ])
+
+AS_IF([test x"$ac_cv_have___va_copy" = xyes],
+  [AC_DEFINE([HAVE___VA_COPY], [1], [defined if __va_copy(dest,src) is available])])
+
+AS_IF([test x"$ac_cv_have_va_copy" = xyes -o x"$ac_cv_have___va_copy" = xyes],
+  [AC_DEFINE([HAVE_VA_COPY_VARIANT], [1], [defined if va_copy or __va_copy is available])])
+
+AS_IF([test x"$ac_cv_have_va_copy" != xyes -a x"$ac_cv_have___va_copy" != xyes],
+  [AC_MSG_WARN([No va_copy(dest,src) or __va_copy(dest,src) available - may truncate oversized logs])])
+
 AC_CHECK_HEADER([sys/select.h],
     [AC_DEFINE([HAVE_SYS_SELECT_H], [1],
         [Define to 1 if you have <sys/select.h>.])])
diff --git a/docs/nut.dict b/docs/nut.dict
index c5ca2636a9..fa853d674f 100644
--- a/docs/nut.dict
+++ b/docs/nut.dict
@@ -1,4 +1,4 @@
-personal_ws-1.1 en 3502 utf-8
+personal_ws-1.1 en 3503 utf-8
 AAC
 AAS
 ABI
@@ -2902,6 +2902,7 @@ reposurgeon
 repotec
 req
 resetter
+resizing
 resolv
 restoreconf
 resync
diff --git a/drivers/upsdrvquery.c b/drivers/upsdrvquery.c
index d5d8a5b314..4a6fbce88f 100644
--- a/drivers/upsdrvquery.c
+++ b/drivers/upsdrvquery.c
@@ -363,12 +363,21 @@ ssize_t upsdrvquery_read_timeout(udq_pipe_conn_t *conn, struct timeval tv) {
 		ret = -1;
 #endif  /* WIN32 */
 
-	upsdebugx(ret > 0 ? 5 : 6,
-		"%s: received %" PRIiMAX " bytes from driver socket: %s",
-		__func__, (intmax_t)ret, (ret > 0 ? conn->buf : "<null>"));
-	if (ret > 0 && conn->buf[0] == '\0')
-		upsdebug_hex(5, "payload starts with zero byte: ",
-			conn->buf, ((size_t)ret > sizeof(conn->buf) ? sizeof(conn->buf) : (size_t)ret));
+	if (ret > 0) {
+		size_t len = (size_t)ret > sizeof(conn->buf) ? sizeof(conn->buf) : (size_t)ret;
+
+		upsdebugx(5, "%s: received %" PRIiMAX " bytes from driver socket: %.*s",
+			__func__, (intmax_t)ret, (int)len, conn->buf);
+
+		if (conn->buf[0] == '\0') {
+			upsdebug_hex(5, "payload starts with zero byte: ",
+				conn->buf, len);
+		}
+	} else {
+		upsdebugx(6, "%s: received %" PRIiMAX " bytes from driver socket: <null>",
+			__func__, (intmax_t)ret);
+	}
+
 	return ret;
 }
 
@@ -483,7 +492,11 @@ ssize_t upsdrvquery_prepare(udq_pipe_conn_t *conn, struct timeval tv) {
 	}
 
 	/* Check that we can have a civilized dialog --
-	 * nope, this one is for network protocol */
+	 * nope, this one is for network protocol
+	 *
+	 * FIXME: strcmp(conn->buf, "ON") -> buf is NOT null terminated!
+	 * The above FIXME is not the reason this block is/was commented out.
+	 */
 /*
 	if (upsdrvquery_write(conn, "GET TRACKING\n") < 0)
 		goto socket_error;
@@ -745,7 +758,8 @@ ssize_t upsdrvquery_oneshot_conn(
 	}
 
 	if (buf) {
-		snprintf(buf, bufsz, "%s", conn->buf);
+		size_t len = strnlen(conn->buf, sizeof(conn->buf));
+		snprintf(buf, bufsz, "%.*s", (int)len, conn->buf);
 	}
 
 finish:
diff --git a/include/common.h b/include/common.h
index f26e8b4a7e..3cd6f226f1 100644
--- a/include/common.h
+++ b/include/common.h
@@ -546,6 +546,13 @@ int vsnprintfcat(char *dst, size_t size, const char *fmt, va_list ap);
 int snprintfcat(char *dst, size_t size, const char *fmt, ...)
 	__attribute__ ((__format__ (__printf__, 3, 4)));
 
+/* Define a missing va_copy using __va_copy, if available: */
+#ifndef HAVE_VA_COPY
+# ifdef HAVE___VA_COPY
+#  define va_copy(dest, src) __va_copy(dest, src)
+# endif
+#endif
+
 /* Mitigate the inherent insecurity of dynamically constructed formatting
  * strings vs. a fixed vararg list with its amounts and types of variables
  * printed by this or that method and pre-compiled in the program.


Reviews:
user: login: jimklimov
body: Mostly looks reasonable, but in a few points either I misunderstood, or the code should be improved. Thanks!
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-20T13:05:28Z
user: login: jimklimov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T13:08:09Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:12:48Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:16:52Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:29:57Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:32:19Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:36:52Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:39:58Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T13:54:07Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T14:55:15Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T15:08:55Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T15:09:22Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T15:09:43Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T15:10:31Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T15:47:03Z
user: login: jimklimov
body: Super, thanks!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T06:54:29Z

Review comments:
diff_hunk: @@ -3206,9 +3206,22 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	 * or the calling methods should check it against their
 	 * "fmt_dynamic" expectations). */
 	do {
+#ifdef HAVE_VA_COPY_VARIANT
+		va_list	va_snprintf;
+
+		/* va_copy() to avoid mangling on re-use (see issue #2948),
+		 * this lets us retry safely vsnprintf() with the VA copies */
+		va_copy(va_snprintf, va);
+		ret = vsnprintf(buf, bufsize, fmt, va_snprintf);
+		va_end(va_snprintf);
+#else
+		/* Without va_copy(), we cannot safely retry vsnprintf()
+		 * and will accept truncation if the buffer is too small */
 		ret = vsnprintf(buf, bufsize, fmt, va);
+#endif
 
 		if ((ret < 0) || ((uintmax_t)ret >= (uintmax_t)bufsize)) {
+#ifdef HAVE_VA_COPY_VARIANT
path: common/common.c
user: login: jimklimov
body: So far looking cursorily so maybe I've missed something... but can you please clarify (maybe add a comment in code to help maintenance later) why this block that was present earlier is now to be built only if `HAVE_VA_COPY_VARIANT`? Notably the `va_copy()` method is not called inside.
created_at: 2025-05-20T12:54:15Z
author_association: MEMBER
diff_hunk: @@ -1433,6 +1433,64 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    save_CFLAGS="$CFLAGS"
+    CFLAGS="$CFLAGS -Werror=implicit-function-declaration"
+
+    AC_COMPILE_IFELSE(
path: configure.ac
user: login: jimklimov
body: FYI, in NUT we also have the `AX_RUN_OR_LINK_IFELSE` macro (which runs native builds and "only links" non-native, which may be the next step beyond compilation anyway). Does this check seem like it could benefit from this for portability (make sure methods are implemented in LIBS pulled in at this point, and do not crash if we run this, etc.) - albeit at an overhead cost during each `configure` run?..

Same question/suggestion applies to the next check for `__va_copy()`.
created_at: 2025-05-20T12:57:21Z
author_association: MEMBER
diff_hunk: @@ -364,8 +364,10 @@ ssize_t upsdrvquery_read_timeout(udq_pipe_conn_t *conn, struct timeval tv) {
 #endif  /* WIN32 */
 
 	upsdebugx(ret > 0 ? 5 : 6,
-		"%s: received %" PRIiMAX " bytes from driver socket: %s",
-		__func__, (intmax_t)ret, (ret > 0 ? conn->buf : "<null>"));
+		"%s: received %" PRIiMAX " bytes from driver socket: %.*s",
+		__func__, (intmax_t)ret, (int)(ret > INT_MAX ? INT_MAX : ret),
path: drivers/upsdrvquery.c
user: login: jimklimov
body: According to code just above, we can end up with `ret = -1` for example. I think these Elvis-style arguments should take that into account (for `.*` and for `s` formats), to behave sanely if `ret <= 0`?
created_at: 2025-05-20T13:00:56Z
author_association: MEMBER
diff_hunk: @@ -486,7 +488,7 @@ ssize_t upsdrvquery_prepare(udq_pipe_conn_t *conn, struct timeval tv) {
 		goto socket_error;
 	if (upsdrvquery_read_timeout(conn, tv) < 1)
 		goto socket_error;
-	if (strcmp(conn->buf, "ON")) {
+	if (strcmp(conn->buf, "ON")) { -- WARNING: conn->buf is NOT null terminated --
path: drivers/upsdrvquery.c
user: login: jimklimov
body: Was this supposed to be a comment? ;)
I see it is inside a comment block, but probably a `//` is safer in this context, even if we discourage it in buildable code. Just in case it gets enabled, many compilers (or their chosen standards) would not be utterly confused.
created_at: 2025-05-20T13:02:46Z
author_association: MEMBER
diff_hunk: @@ -546,6 +546,13 @@ int vsnprintfcat(char *dst, size_t size, const char *fmt, va_list ap);
 int snprintfcat(char *dst, size_t size, const char *fmt, ...)
 	__attribute__ ((__format__ (__printf__, 3, 4)));
 
+/* Define a missing va_copy using __va_copy, if available: */
+#ifndef va_copy
path: include/common.h
user: login: jimklimov
body: Do we have a guarantee that `va_copy` is a macro wherever defined (e.g. not a method)? Maybe better check for `#ifndef HAVE_VA_COPY` more portably? We define one in `configure.ac` anyway :)
created_at: 2025-05-20T13:04:12Z
author_association: MEMBER
diff_hunk: @@ -1433,6 +1433,64 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    save_CFLAGS="$CFLAGS"
+    CFLAGS="$CFLAGS -Werror=implicit-function-declaration"
path: configure.ac
user: login: jimklimov
body: I think these options are compiler-specific and should be limited to `AS_IF([test "x${CLANGCC}" = "xyes" || test "x${GCC}" = "xyes"], ...` as some other parts of the script already do. Also below in the next check.
created_at: 2025-05-20T13:07:39Z
author_association: MEMBER
diff_hunk: @@ -3206,9 +3206,22 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	 * or the calling methods should check it against their
 	 * "fmt_dynamic" expectations). */
 	do {
+#ifdef HAVE_VA_COPY_VARIANT
+		va_list	va_snprintf;
+
+		/* va_copy() to avoid mangling on re-use (see issue #2948),
+		 * this lets us retry safely vsnprintf() with the VA copies */
+		va_copy(va_snprintf, va);
+		ret = vsnprintf(buf, bufsize, fmt, va_snprintf);
+		va_end(va_snprintf);
+#else
+		/* Without va_copy(), we cannot safely retry vsnprintf()
+		 * and will accept truncation if the buffer is too small */
 		ret = vsnprintf(buf, bufsize, fmt, va);
+#endif
 
 		if ((ret < 0) || ((uintmax_t)ret >= (uintmax_t)bufsize)) {
+#ifdef HAVE_VA_COPY_VARIANT
path: common/common.c
body: Because if the variadic arguments do not get copied, but rather instantly consumed from the original set on the first try (due to lack of `va_copy()`), the do-while would keep running and retrying upsized buffers on the original set of (already consumed) arguments which would result in the mangling we've seen resulting in the issue #2948. Not building this block will make it fall through to the truncation case instead and not retry with the consumed, non-copied set of VAs. This basically turns the retry-logic into just a simple truncation warning for those systems.
created_at: 2025-05-20T13:12:48Z
author_association: NONE
diff_hunk: @@ -486,7 +488,7 @@ ssize_t upsdrvquery_prepare(udq_pipe_conn_t *conn, struct timeval tv) {
 		goto socket_error;
 	if (upsdrvquery_read_timeout(conn, tv) < 1)
 		goto socket_error;
-	if (strcmp(conn->buf, "ON")) {
+	if (strcmp(conn->buf, "ON")) { -- WARNING: conn->buf is NOT null terminated --
path: drivers/upsdrvquery.c
body: It was specifically so this doesn't just get uncommented on a whim and ends up compiling without problem and causing the same issue all over again. Can just turn this into `//` instead if you prefer, although old standards would complain about that also.
created_at: 2025-05-20T13:16:52Z
author_association: NONE
diff_hunk: @@ -546,6 +546,13 @@ int vsnprintfcat(char *dst, size_t size, const char *fmt, va_list ap);
 int snprintfcat(char *dst, size_t size, const char *fmt, ...)
 	__attribute__ ((__format__ (__printf__, 3, 4)));
 
+/* Define a missing va_copy using __va_copy, if available: */
+#ifndef va_copy
path: include/common.h
body: I've researched this in depth before and everywhere I looked this was only ever a macro.

The rationale behind `va_copy` and not HAVE_* for the definition itself was:

- If the environment already provides a va_copy macro, we should trust it (to know best) and not override it.
- If it doesn't, we can define it with a va_copy variant that fits our expecations, we wouldn't override.
- If we rely on HAVE_VA_COPY alone, we risk defining va_copy even when the environment already provides a valid one, possibly leading to conflicts or subtle bugs (if our test expectation of va_copy diverges from system's implementation).
- Conversely, the HAVE_VA_COPY, HAVE___VA_COPY, HAVE_VA_COPY_VARIANT logic can be used by the calling code to ensure that a test compliant va_copy must exist and behave according to tested expectations, not just any va_copy (hence not using `#ifdef va_copy` in the calling code places but HAVE_*)

Of course this was just my personal thought process, so happy to revisit this (or have it patched over 😉 )

created_at: 2025-05-20T13:29:57Z
author_association: NONE
diff_hunk: @@ -364,8 +364,10 @@ ssize_t upsdrvquery_read_timeout(udq_pipe_conn_t *conn, struct timeval tv) {
 #endif  /* WIN32 */
 
 	upsdebugx(ret > 0 ? 5 : 6,
-		"%s: received %" PRIiMAX " bytes from driver socket: %s",
-		__func__, (intmax_t)ret, (ret > 0 ? conn->buf : "<null>"));
+		"%s: received %" PRIiMAX " bytes from driver socket: %.*s",
+		__func__, (intmax_t)ret, (int)(ret > INT_MAX ? INT_MAX : ret),
path: drivers/upsdrvquery.c
body: Good point, this should indeed be guarded against `< 0`, `0` itself would print an empty string if I'm not mistaken.
created_at: 2025-05-20T13:32:18Z
author_association: NONE
diff_hunk: @@ -1433,6 +1433,64 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    save_CFLAGS="$CFLAGS"
+    CFLAGS="$CFLAGS -Werror=implicit-function-declaration"
path: configure.ac
body: That's a good point, how do you usually handle such test cases requiring to fail when they need to, while maximising portability but also not missing out testing for needed bits on some platforms?
created_at: 2025-05-20T13:36:52Z
author_association: NONE
diff_hunk: @@ -1433,6 +1433,64 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    save_CFLAGS="$CFLAGS"
+    CFLAGS="$CFLAGS -Werror=implicit-function-declaration"
+
+    AC_COMPILE_IFELSE(
path: configure.ac
body: Thanks for the pointer. I agree that AX_RUN_OR_LINK_IFELSE might be a stronger check, especially when it might compile but not actually link or behave properly. For va_copy, since it seemed to always be a macro and typically either work or be missing outright, I leaned toward the lighter AC_COMPILE_IFELSE here - happy to revisit/have it patched over as well here.
created_at: 2025-05-20T13:39:58Z
author_association: NONE
diff_hunk: @@ -3206,9 +3206,22 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	 * or the calling methods should check it against their
 	 * "fmt_dynamic" expectations). */
 	do {
+#ifdef HAVE_VA_COPY_VARIANT
+		va_list	va_snprintf;
+
+		/* va_copy() to avoid mangling on re-use (see issue #2948),
+		 * this lets us retry safely vsnprintf() with the VA copies */
+		va_copy(va_snprintf, va);
+		ret = vsnprintf(buf, bufsize, fmt, va_snprintf);
+		va_end(va_snprintf);
+#else
+		/* Without va_copy(), we cannot safely retry vsnprintf()
+		 * and will accept truncation if the buffer is too small */
 		ret = vsnprintf(buf, bufsize, fmt, va);
+#endif
 
 		if ((ret < 0) || ((uintmax_t)ret >= (uintmax_t)bufsize)) {
+#ifdef HAVE_VA_COPY_VARIANT
path: common/common.c
body: Fwiw, the comment two lines above was supposed to address this, but can move it down right above the excluded block for better visibility?
created_at: 2025-05-20T13:54:07Z
author_association: NONE
diff_hunk: @@ -1433,6 +1433,64 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    save_CFLAGS="$CFLAGS"
+    CFLAGS="$CFLAGS -Werror=implicit-function-declaration"
path: configure.ac
body: Actually, on second thought, good thing you brought this up because `AX_RUN_OR_LINK_IFELSE` might exactly remove this reliance on specific CFLAGS tripping up the test for some systems - I've amended to what you suggested in the other comment, using `AX_RUN_OR_LINK_IFELSE`, which should be most(?) portable here (two flies with one... how do you say?)
created_at: 2025-05-20T14:55:15Z
author_association: NONE
diff_hunk: @@ -486,7 +488,7 @@ ssize_t upsdrvquery_prepare(udq_pipe_conn_t *conn, struct timeval tv) {
 		goto socket_error;
 	if (upsdrvquery_read_timeout(conn, tv) < 1)
 		goto socket_error;
-	if (strcmp(conn->buf, "ON")) {
+	if (strcmp(conn->buf, "ON")) { -- WARNING: conn->buf is NOT null terminated --
path: drivers/upsdrvquery.c
body: Fixed up to a `FIXME:` (ha) which should also be more grep-able.
created_at: 2025-05-20T15:08:55Z
author_association: NONE
diff_hunk: @@ -364,8 +364,10 @@ ssize_t upsdrvquery_read_timeout(udq_pipe_conn_t *conn, struct timeval tv) {
 #endif  /* WIN32 */
 
 	upsdebugx(ret > 0 ? 5 : 6,
-		"%s: received %" PRIiMAX " bytes from driver socket: %s",
-		__func__, (intmax_t)ret, (ret > 0 ? conn->buf : "<null>"));
+		"%s: received %" PRIiMAX " bytes from driver socket: %.*s",
+		__func__, (intmax_t)ret, (int)(ret > INT_MAX ? INT_MAX : ret),
path: drivers/upsdrvquery.c
body: Fixed up, also a comparison issue that snuck in, hope that does it.
created_at: 2025-05-20T15:09:22Z
author_association: NONE
diff_hunk: @@ -1433,6 +1433,64 @@ AS_IF([test x"${ac_cv_func_strdup}" = xyes],
     [AC_MSG_WARN([Required C library routine strdup not found; try adding -D_POSIX_C_SOURCE=200112L])]
     )
 
+AC_CACHE_CHECK([for va_copy(dest,src)],
+  [ac_cv_have_va_copy],
+  [
+    save_CFLAGS="$CFLAGS"
+    CFLAGS="$CFLAGS -Werror=implicit-function-declaration"
+
+    AC_COMPILE_IFELSE(
path: configure.ac
body: Fixed up to `AX_RUN_OR_LINK_IFELSE`, should also be more portable and not depend on `CFLAGS`.
created_at: 2025-05-20T15:09:43Z
author_association: NONE
diff_hunk: @@ -3206,9 +3206,22 @@ static void vupslog(int priority, const char *fmt, va_list va, int use_strerror)
 	 * or the calling methods should check it against their
 	 * "fmt_dynamic" expectations). */
 	do {
+#ifdef HAVE_VA_COPY_VARIANT
+		va_list	va_snprintf;
+
+		/* va_copy() to avoid mangling on re-use (see issue #2948),
+		 * this lets us retry safely vsnprintf() with the VA copies */
+		va_copy(va_snprintf, va);
+		ret = vsnprintf(buf, bufsize, fmt, va_snprintf);
+		va_end(va_snprintf);
+#else
+		/* Without va_copy(), we cannot safely retry vsnprintf()
+		 * and will accept truncation if the buffer is too small */
 		ret = vsnprintf(buf, bufsize, fmt, va);
+#endif
 
 		if ((ret < 0) || ((uintmax_t)ret >= (uintmax_t)bufsize)) {
+#ifdef HAVE_VA_COPY_VARIANT
path: common/common.c
body: Fixed up, added a more detailed comment to the block in question - logic remains for now.
created_at: 2025-05-20T15:10:31Z
author_association: NONE
diff_hunk: @@ -546,6 +546,13 @@ int vsnprintfcat(char *dst, size_t size, const char *fmt, va_list ap);
 int snprintfcat(char *dst, size_t size, const char *fmt, ...)
 	__attribute__ ((__format__ (__printf__, 3, 4)));
 
+/* Define a missing va_copy using __va_copy, if available: */
+#ifndef va_copy
path: include/common.h
body: I've changed this to your suggestion for now so we can see how this behaves in CI, and you're definitely more experted at portability concerns than me from looking at this project (and the CI... hats off...)
created_at: 2025-05-20T15:47:03Z
author_association: NONE

PR comments:
user: login: AppVeyorBot
created_at: 2025-05-20T08:11:25Z
body: :x: [Build nut 2.8.3.3180-master failed](https://ci.appveyor.com/project/nut-travis/nut/builds/52086098) (commit https://github.com/networkupstools/nut/commit/bd2d526a1f by @sebastiankuttnig)
author_association: NONE
user: login: ghost
created_at: 2025-05-20T08:19:21Z
body: > ❌ [Build nut 2.8.3.3180-master failed](https://ci.appveyor.com/project/nut-travis/nut/builds/52086098) (commit [bd2d526a1f](https://github.com/networkupstools/nut/commit/bd2d526a1f) by @sebastiankuttnig)

> Build execution time has reached the maximum allowed time for your plan (60 minutes).

Seems unrelated, but would definitely appreciate a sanity-check on this and particularly if other parts here and elsewhere may make re-use of variadic arguments in a way that would mangle them as we've seen before (and also need fixing with `va_copy`).
author_association: NONE
user: login: AppVeyorBot
created_at: 2025-05-20T09:08:15Z
body: :white_check_mark: [Build nut 2.8.3.3181-master completed](https://ci.appveyor.com/project/nut-travis/nut/builds/52086227) (commit https://github.com/networkupstools/nut/commit/e6b199e7f0 by @sebastiankuttnig)
author_association: NONE
user: login: ghost
created_at: 2025-05-20T13:42:14Z
body: Thanks for the detailed feedback, happy to collaborate since this is a crucial piece of the codebase and I'm not so well versed with the broader ecosystem yet, wanted to kick off a PR to get things rolling - hope my thought process so far made at least some sense! 😀 
author_association: NONE
user: login: AppVeyorBot
created_at: 2025-05-20T15:21:54Z
body: :x: [Build nut 2.8.3.3184-master failed](https://ci.appveyor.com/project/nut-travis/nut/builds/52089267) (commit https://github.com/networkupstools/nut/commit/3f899a5c87 by @sebastiankuttnig)
author_association: NONE
user: login: AppVeyorBot
created_at: 2025-05-20T16:15:33Z
body: :white_check_mark: [Build nut 2.8.3.3185-master completed](https://ci.appveyor.com/project/nut-travis/nut/builds/52089305) (commit https://github.com/networkupstools/nut/commit/c21a0de91f by @sebastiankuttnig)
author_association: NONE
user: login: AppVeyorBot
created_at: 2025-05-22T08:22:44Z
body: :x: [Build nut 2.8.3.3191-master failed](https://ci.appveyor.com/project/nut-travis/nut/builds/52100947) (commit https://github.com/networkupstools/nut/commit/8455040893 by @jimklimov)
author_association: NONE
user: login: AppVeyorBot
created_at: 2025-05-22T09:20:07Z
body: :white_check_mark: [Build nut 2.8.3.3192-master completed](https://ci.appveyor.com/project/nut-travis/nut/builds/52101118) (commit https://github.com/networkupstools/nut/commit/c45177febb by @jimklimov)
author_association: NONE
user: login: ghost
created_at: 2025-05-27T08:08:22Z
body: All good from my side here as well, if it passes CI. 👍 
author_association: NONE