C / STRINGS AND BUFFER SAFETY
Building strings piece by piece without overflowing
Append text to a fixed buffer with a cursor and a remaining-space count, detect truncation from snprintf's return value, and never let size_t underflow.
What you will learn
- Track cap and used yourself; compute room = cap - used before every append.
- Read snprintf's return as the length needed, and treat n >= room as truncation.
- Clamp used to cap - 1 on truncation so cap - used can never wrap.
- Latch a truncated flag so many appends need only one check at the end.
Understanding Building strings piece by piece without overflowing
A fixed C buffer is a tape of known length with a write head somewhere in it, and safe appending means never losing track of two numbers: the capacity you were given, and how many characters you have already written. If you keep only the buffer, every append has to rediscover the head with strlen, which rescans the whole prefix and still tells you nothing about how much room is left. Keeping a used counter beside the buffer turns each append into a bounded write at buf + used with a size of cap - used, and it makes a loop of appends linear instead of quadratic.
snprintf is what makes the pattern checkable, because it returns the length the finished string would have had, not the number of bytes it managed to store. So the truncation test is n >= room, not n >= cap, and on a truncated call snprintf has stored exactly room - 1 characters plus a terminator. Skip that test and write used += n, and used moves past cap; the next cap - used is unsigned subtraction, so it wraps to an enormous value, buf + used already points outside the array, and snprintf will believe both and write there.
That leaves the policy question: what happens when the pieces do not fit. A latched truncation flag lets you fire off a dozen appends and check once at the end, which reads well but leaves a half-written final field in the buffer. If the complete string must survive, measure first: snprintf(NULL, 0, fmt, ...) returns the required length without writing anything, so you can allocate that plus one and then format for real, paying for formatting the arguments twice.
<stdio.h>
<string.h>
/* Append text at *used. Returns 1 if it fit, 0 if it was truncated.
buf stays NUL-terminated either way, and *used stays below cap. */
static int append(char *buf, size_t cap, size_t *used, const char *text)
{
size_t room = cap - *used; /* cannot wrap: *used <= cap - 1 */
int n = snprintf(buf + *used, room, "%s", text);
if (n < 0)
return 0;
if ((size_t)n >= room) { /* only room - 1 chars were stored */
*used = cap - 1;
return 0;
}
*used += (size_t)n;
return 1;
}
int main(void)
{
const char *parts[] = { "GET ", "/index.html", " HTTP/1.1", "\r\n" };
char line[24];
size_t used = 0;
size_t i;
line[0] = '\0'; /* a valid empty string to append to */
for (i = 0; i < 4; i++) {
if (!append(line, sizeof line, &used, parts[i])) {
printf("part %zu did not fit\n", i);
break;
}
}
printf("used=%zu cap=%zu strlen=%zu\n", used, sizeof line, strlen(line));
printf("[%s]\n", line);
return 0;
}
Every append must be sized by the room actually left, and used must never be allowed past cap - 1, because the moment it is, cap - used wraps into permission to write anywhere.
Worked examples
How used += snprintf() goes wrong
Shows the arithmetic that turns one unchecked return value into an out-of-bounds size, without performing the illegal write.
<stdio.h>
int main(void)
{
char buf[16];
size_t used = 0;
int n;
n = snprintf(buf, sizeof buf, "%s", "user=alice");
used += (size_t)n; /* harmless here: it fitted */
printf("call 1: n=%d used=%zu\n", n, used);
n = snprintf(buf + used, sizeof buf - used, "%s", "&role=admin");
used += (size_t)n; /* the bug: n is 11, only 5 fitted */
printf("call 2: n=%d used=%zu buf=[%s]\n", n, used, buf);
/* What a third append would be handed, without making the call. */
printf("next offset = %zu\n", used);
printf("next size = %llu\n", (unsigned long long)(sizeof buf - used));
return 0;
}
Example explained
Line 1Call 2 was given size 6, so it stored 5 characters plus a terminator, yet returned 11: the length "&role=admin" needed.
Line 2used += n therefore lands on 21, five past the end of a 16-byte array, and nothing illegal has happened yet so no warning fires.
Line 3sizeof buf - used is unsigned arithmetic, so 16 - 21 wraps instead of going negative; the digits depend on the width of size_t (4294967291 in a 32-bit build).
Line 4A third snprintf given offset 21 and that size would be told it owns nearly the whole address space starting past buf, which is a plain out-of-bounds write.
A builder struct with a latched overflow flag
Shows a reusable printf-style append helper that keeps the cursor inside the buffer and records overflow once.
<stdarg.h>
<stdio.h>
typedef struct {
char *buf;
size_t cap;
size_t len;
int full;
} Builder;
static void addf(Builder *b, const char *fmt, ...)
{
va_list ap;
size_t room;
int n;
if (b->full)
return; /* already overflowed: stop writing */
room = b->cap - b->len;
va_start(ap, fmt);
n = vsnprintf(b->buf + b->len, room, fmt, ap);
va_end(ap);
if (n < 0 || (size_t)n >= room) {
b->full = 1;
b->len = b->cap - 1;
} else {
b->len += (size_t)n;
}
}
int main(void)
{
char out[32];
Builder b;
b.buf = out;
b.cap = sizeof out;
b.len = 0;
b.full = 0;
out[0] = '\0';
addf(&b, "id=%d", 4711);
addf(&b, ";name=%s", "grace");
addf(&b, ";score=%.2f", 99.5);
addf(&b, ";tags=%s", "alpha,beta,gamma");
printf("%s\n", out);
printf("len=%zu full=%d\n", b.len, b.full);
return 0;
}
Example explained
Line 1room is recomputed from b->len on every call, and b->len is clamped to cap - 1, so room is always between 1 and cap and the subtraction never wraps.
Line 2vsnprintf consumes the va_list that va_start built, which is what lets addf behave like an ordinary printf wrapper.
Line 3The fourth call had room 2, so only ';' plus the terminator fit; full is latched, so a fifth call would return without writing.
Line 4One test of b.full at the end covers all four appends, so the caller does not have to check each one.
Important notes
snprintf terminates whenever the size argument is greater than zero, so a truncated result is still a valid string; what it will not tell you is how many bytes it stored, which you must derive from room.
snprintf returns int, so test for a negative value before casting to size_t, and remember that a measuring call like snprintf(NULL, 0, ...) formats the arguments an extra time, so never pass side effects such as p++.
Common mistakes
Writing used += snprintf(buf + used, sizeof buf - used, ...) with no check: after one truncation used passes sizeof buf, the next sizeof buf - used wraps to a near-SIZE_MAX size, and snprintf writes far past the array.
Passing sizeof buf instead of sizeof buf - used on the second and later appends: each call believes it owns the whole array while writing at buf + used, so it can run used bytes off the end.
Leaving out buf[0] = '\0' before the first append and then calling strlen or printing the buffer when nothing was appended: the bytes are indeterminate and the read runs past the array.
Try it yourself
Change, predict, then run
Copy the main example and add append_int(char *buf, size_t cap, size_t *used, int value) that formats with "%d" using the same room and return-value checks. Use it with a 12-byte buffer to build 1000,2000,3000 and print used plus which number first failed to fit.
Open the C workspaceCheck your understanding
A 16-byte buffer already holds 12 characters, so used == 12. You call snprintf(buf + used, sizeof buf - used, "%s", "abcdefgh") and it returns 8. What actually happened?
- It stored 8 characters, so used should become 20.
- It stored 4 characters and no terminator, so you must set buf[15] = '\0' yourself.
- It truncated: 3 characters plus a terminator were stored, and used must not be advanced by 8.
- It failed and stored nothing, so used should stay at 12.
Show answer
The return value is the length the format needed, not the number of bytes stored: room is 4, so snprintf stored room - 1 == 3 characters and terminated, which is exactly the n >= room truncation case. Option 0 is the tempting one because advancing by the return value works on every call that fits, but here it puts used at 20, and the next sizeof buf - used wraps to a huge size_t.