Bounded Firmware Formatting: Handle snprintf Truncation Without Advancing Past the Buffer

Advance a formatting cursor only after confirming that the formatter returned a nonnegative value smaller than the remaining capacity. A successful-looking positive snprintf result can describe characters that did not fit. Adding that result unconditionally can move the next write past the buffer even though the first call respected its own limit.

This guide uses the C11 snprintf/vsnprintf contract. The original append fixture was executed with GCC 14.2.0 and glibc 2.41 on x86-64 Linux, in the C locale. Firmware using another libc, reduced formatter or vendor patch must qualify that implementation. No MCU execution, ISR suitability or formatting-time bound is claimed.

Keep three quantities separate

Record total capacity, committed characters and capacity remaining from the current cursor. Capacity includes room for the terminating null character. The formatter’s return is a fourth quantity: the length that would have been produced with sufficient space, excluding that terminator.

The WG14 N1570 C11 draft, section 7.21.6.5, specifies this return contract, negative encoding-error results and the zero-capacity case. Its vsnprintf section applies the corresponding formatted-output behavior to a va_list. The glibc 2.41 manual documents the selected host implementation’s interface.

Bounded append validates the cursor, calls vsnprintf with remaining capacity, checks negative result before unsigned conversion, rejects a result at least as large as remaining capacity, and advances only on a complete append.
The cursor records committed text, not requested output length. Failed appends restore the previous logical terminator.

Suppose a hypothetical five-byte buffer already contains “ab.” The remaining capacity is three. Appending “xyz” returns three, while only “xy” and a terminator fit in that remaining region. Treating three as committed bytes would leave the cursor at capacity, with no valid position for the next terminator.

Choose a failure contract before writing the helper

The example below uses an all-or-nothing logical append. On truncation or formatting error, it restores the terminator at the previous committed position and leaves used unchanged. Bytes after that terminator may have been modified; this is not a transactional restoration of the entire backing array.

Its caller owns the buffer exclusively, supplies real storage of at least capacity bytes, and maintains used less than capacity with a terminator at data[used]. Inputs must not overlap the destination. Format strings and argument types must be valid; this helper does not make arbitrary format strings safe. For its text-string contract, callers also exclude embedded null output and side-effecting percent-n conversions.

Validate the result before changing state

This original implementation was compiled and executed in the host fixture. Its translation unit includes stdarg.h, stdint.h and stdio.h, and defines the four status values shown:

enum append_status { APPEND_OK, APPEND_TRUNCATED,
                     APPEND_ERROR, APPEND_INVALID };
struct textbuf { char *data; size_t capacity, used; };

static enum append_status append(struct textbuf *b,
                                const char *format, ...) {
    if (!b || !b->data || !format || b->capacity == 0 ||
        b->used >= b->capacity)
        return APPEND_INVALID;
    size_t remaining = b->capacity - b->used;
    va_list args;
    va_start(args, format);
    int n = vsnprintf(b->data + b->used, remaining,
                      format, args);
    va_end(args);
    if (n < 0) {
        b->data[b->used] = '\0';
        return APPEND_ERROR;
    }
    if ((uintmax_t)n >= (uintmax_t)remaining) {
        b->data[b->used] = '\0';
        return APPEND_TRUNCATED;
    }
    b->used += (size_t)n;
    return APPEND_OK;
}

A negative result is a formatting error, including encoding failure. The selected glibc manual also lists printf-family failures such as EOVERFLOW and ENOMEM; those additional failures were not injected here. The negative test occurs before conversion to an unsigned type. The comparison uses uintmax_t so a hypothetical implementation with size_t narrower than int cannot truncate a positive result before deciding whether it fits. Only after proving that n is smaller than remaining does the helper convert it to size_t and add it to used.

Because remaining equals capacity minus used, the successful addition preserves used less than capacity. This is the central invariant. Do not duplicate the cursor update in a caller or “recover” from APPEND_TRUNCATED by adding the rejected length anyway. Decide whether the whole record should be rejected, replaced by a shorter representation or reported through another bounded channel.

Distinguish exact fit from no room

Case Expected result Cursor consequence
n equals remaining minus one Complete output plus terminator fits Advance by n
n equals remaining Truncated Keep previous used
Remaining equals one, empty output Complete empty append No change
Remaining equals one, nonempty output Truncated No change
Formatter returns negative Formatting error, including encoding failure Restore prior terminator

A standalone snprintf call with a zero size may use a null destination to query the required length. That does not make a zero-capacity textbuf a valid append destination: this helper deliberately rejects it because its invariant requires a stored terminator. Keep sizing queries and committed-string operations as separate interfaces.

Run failure cases against the actual libc

The executed fixture appended “ab” then “cd” into five bytes and obtained four committed characters plus the terminator. A subsequent nonempty append was rejected without changing that prefix. Another test attempted “xyz” after “ab” and verified logical rollback. A canary beyond the declared capacity remained unchanged.

In glibc 2.41’s C locale, a wide-character conversion of U+0100 produced the negative encoding-error path; the helper retained the previous prefix. Zero-capacity rejection, a separate sizing query and capacity-one empty/nonempty cases also passed. Do not assume the same encoding-error trigger works under a different locale or reduced target formatter.

The successful host run used AddressSanitizer and UndefinedBehaviorSanitizer. LeakSanitizer could not run in the execution environment and was explicitly disabled; no leak-check success is claimed. These tests cover the stated helper cases, not every formatter feature or malformed argument list.

Release the invariant with the formatter

Record the linked libc version and enabled formatting features, including floating-point and wide-character support where used. Test the longest real records, each optional field combination and every rejection branch. Keep format strings controlled and preserve compiler format diagnostics. Acceptance means every call either commits a complete bounded append or returns an explicit failure while keeping the previous logical prefix valid.

Similar Posts