Lesson 14 / 15
Bounds Checking
record_create's limit parameter takes its length not from the source but from the caller; the lesson runs how this parameter prevents a buffer overflow, snprintf's truncation notification, and whose responsibility the length is.
Published Updated
In this lesson
- Recognizes record_create's limit parameter as a contract given by the caller
- Copies a source of unknown length into a fixed-size buffer with snprintf's truncation notification
- Measures that the limit is written in the caller, not the source, with two different limit values
Contents
The previous lesson measured record_create‘s cleanup on the error path; that error path
arose from a single condition: the given name’s length exceeded the limit parameter. This
lesson puts that parameter itself at the center. limit is not a constant of the language or
of the owner.c unit — it is a value the caller gives on every call, and it is exactly
that value that prevents a buffer overflow.
The previous two lessons measured the “unowned” and “callee” classes; this lesson moves to
the third class: caller. The difference comes down to one question — who prevents a
buffer overflow? In the previous lesson the answer was “the called function itself”:
record_create bounded the block it allocated itself. In this lesson, neither of the two
functions measured (record_create‘s limit, bounded_copy‘s dest_size) sets its own
limit — the caller sets the limit and the function only complies with it. Whether the
limit is correct is the caller’s responsibility, not the function’s.
The Caller Gives the Length
Looking at the signature record_create(const char *name, size_t limit), where limit comes
from is not visible; the signature only takes a size_t. But it is written in the comment in
the contract’s header file: the function checks name’s length against limit and returns
NULL if it does not fit. This check is not part of the language — strlen knows no limit,
memcpy knows no limit; the only thing that knows the limit is the comparison inside
record_create’s own body.
What needs attention at this point is where limit comes from. size_t limit appearing
in the signature does not mean the value is constant or safe — the caller may derive it from
a constant, from user input, or from another computation. record_create does not check
whether the limit given to it is correct, it only checks whether name fits that
limit. If the limit itself is wrong — for example larger than the real destination’s
capacity — the function still works correctly, but what the check protects is no longer the
real destination, it is the number the caller wrongly reported. This distinction is taken up
again in the “Bounding Measurement” section at the end of the lesson.
// counter.h — a wrapper that counts every allocation and every free #ifndef COUNTER_H #define COUNTER_H #include <stddef.h> void *count_alloc(size_t size); void *count_grow(void *old, size_t size); void count_free(void *block); int allocated_count(void); int freed_count(void); #endif
// counter.c — the counters live only in this unit
#include <stdlib.h>
#include "counter.h"
static int allocated = 0;
static int freed = 0;
void *count_alloc(size_t size) {
void *block = malloc(size);
if (block != NULL) { allocated++; }
return block;
}
void *count_grow(void *old, size_t size) {
int new_block = (old == NULL);
void *grown = realloc(old, size);
if (grown != NULL && new_block) { allocated++; }
return grown;
}
void count_free(void *block) {
if (block != NULL) { freed++; }
free(block);
}
int allocated_count(void) { return allocated; }
int freed_count(void) { return freed; }
// owner.h — the record's inside is hidden, the ownership contract is in the comments #ifndef OWNER_H #define OWNER_H #include <stddef.h> typedef struct Record Record; /* The caller owns it and frees it with record_destroy. If the name does not fit the limit, returns NULL; the function frees what it allocated so far. */ Record *record_create(const char *name, size_t limit); /* Frees the record together with the name buffer inside it. */ void record_destroy(Record *r); /* The record owns it: the returned pointer is valid until the record is destroyed, and the caller does not free it. */ const char *record_name(const Record *r); /* The caller owns it: the returned copy is freed with count_free. */ char *record_name_copy(const Record *r); #endif
// owner.c — the unit where the contract is carried out
#include <string.h>
#include "owner.h"
#include "counter.h"
struct Record {
char *name;
size_t length;
};
Record *record_create(const char *name, size_t limit) {
Record *r = count_alloc(sizeof *r);
if (r == NULL) { return NULL; }
r->length = strlen(name);
if (r->length >= limit) {
count_free(r);
return NULL;
}
r->name = count_alloc(r->length + 1);
if (r->name == NULL) {
count_free(r);
return NULL;
}
memcpy(r->name, name, r->length + 1);
return r;
}
void record_destroy(Record *r) {
if (r == NULL) { return; }
count_free(r->name);
count_free(r);
}
const char *record_name(const Record *r) { return r->name; }
char *record_name_copy(const Record *r) {
char *copy = count_alloc(r->length + 1);
if (copy != NULL) { memcpy(copy, r->name, r->length + 1); }
return copy;
}
RO7. record_create can give two different results when called with the same name but
two different limit values — what decides the result is not name, it is the limit the
caller gives. The measurement below shows exactly this: the same name, with one narrow and
one wide limit.
This observation looks at record_create from a deliberately different angle than the
previous lesson’s measurement of it. The previous lesson held limit constant and
triggered the error path by changing name’s length; this lesson does the opposite — it
holds name constant and changes limit. Both trigger the same comparison (`r->length
= limit
), but showing which side changes clarifies which parameter the contract belongs to: the comparison looks at both variables, but because the question this lesson asks is specifically "who gives the limit," the side that must stay constant isname, and the side that must change islimit`.
Truncating Copy into a Fixed-Size Buffer
record_create allocates and copies the source; but sometimes the destination is a fixed-
size local buffer and the source’s length is not known in advance. In that case using
strcpy leaves the door open to a buffer overflow: strcpy never knows the destination’s
size, it copies the source as-is. snprintf, on the other hand, takes the destination’s size
as a parameter and never exceeds it; when it truncates, it reports this through its return
value — the return value is the total length that would have been written had there been no
truncation.
The difference between these two patterns is clearer when compared with record_create from
the previous section. record_create returns NULL without writing anything when the limit
is exceeded — no partial write is involved. bounded_copy behaves differently: it writes
as much as fits into the destination and separately reports the real length. Both prevent
a buffer overflow, but one says “either it fits fully or I write nothing,” while the other
says “I write as much as fits, but I also tell you how many characters did not fit.” Which
pattern is right depends on what the destination is used for: for data whose integrity must
not be broken, such as a record name, the first is preferred; for text where truncation is
acceptable, such as a log line, the second is.
// truncate.h — declaration of a truncating copy into a bounded destination #ifndef TRUNCATE_H #define TRUNCATE_H #include <stddef.h> /* The destination's size is dest_size. The returned value is the total length that would have been written had there been no truncation; if this value is larger than dest_size, truncation occurred. */ size_t bounded_copy(char *dest, size_t dest_size, const char *source); #endif
// truncate.c — copying without exceeding the limit, using snprintf
#include <stdio.h>
#include "truncate.h"
size_t bounded_copy(char *dest, size_t dest_size, const char *source) {
int needed = snprintf(dest, dest_size, "%s", source);
if (needed < 0) {
return 0;
}
return (size_t)needed;
}
RO8. Comparing bounded_copy’s returned value with dest_size - 1 tells whether
truncation occurred: the return value is the length that does not fit the destination,
not the length actually written to the destination. These two numbers may be the same (no
truncation) or different (truncation) — the lesson shows both in the measurement below.
Comparing against dest_size - 1 follows from the fact that snprintf always adds a
terminating zero byte to the destination: a dest_size-byte space fits at most dest_size - 1 characters plus one terminator.
One Measurement with Two Limits
The single main below measures the same two sources with two different limits: a narrow and
a wide limit for record_create, a small and a sufficient dest_size for bounded_copy.
// measurement.c — measuring the caller-given limit with two different values
#include <stdio.h>
#include "owner.h"
#include "counter.h"
#include "truncate.h"
static void observe(const char *name, const char *result) {
printf("%-28s%s\n", name, result);
}
int main(void) {
char buffer[64];
printf("%-28s%s\n", "observation", "result");
/* --- record_create: same name, two different limits --- */
Record *narrow = record_create("toughness", 6);
snprintf(buffer, sizeof buffer, "record %s", narrow == NULL ? "rejected" : "accepted");
observe("narrow limit (6)", buffer);
Record *wide = record_create("toughness", 32);
snprintf(buffer, sizeof buffer, "record %s", wide == NULL ? "rejected" : "accepted");
observe("wide limit (32)", buffer);
record_destroy(wide);
/* --- bounded_copy: same source, two different destination sizes --- */
char small[8];
size_t needed_small = bounded_copy(small, sizeof small, "toughness class");
snprintf(buffer, sizeof buffer, "needed %zu, fits %zu, %s",
needed_small, sizeof small - 1,
needed_small >= sizeof small ? "truncated" : "not truncated");
observe("small dest (8)", buffer);
char sufficient[32];
size_t needed_sufficient = bounded_copy(sufficient, sizeof sufficient, "toughness class");
snprintf(buffer, sizeof buffer, "needed %zu, fits %zu, %s",
needed_sufficient, sizeof sufficient - 1,
needed_sufficient >= sizeof sufficient ? "truncated" : "not truncated");
observe("sufficient dest (32)", buffer);
return 0;
}
observation result narrow limit (6) record rejected wide limit (32) record accepted small dest (8) needed 15, fits 7, truncated sufficient dest (32) needed 15, fits 31, not truncated
RO9. "toughness" is nine characters; the narrow limit (6) rejects it, the wide limit
(32) accepts the same name — the difference lies not in the source, but in the number the
caller gives. In bounded_copy too, the same source ("toughness class", fifteen characters)
is measured with two different destination sizes: the eight-byte destination is truncated
because it fits seven characters, the thirty-two-byte destination is not truncated because it
comfortably fits fifteen characters — the needed value stays the same across both calls,
only the destination’s capacity changes.
Two of the four observations come through record_create, two through bounded_copy, but
all four share the same structure: same source, two different limits. This repetition is
deliberate — the lesson does not have a single piece of evidence, it has two independent
pieces of evidence in two independent functions showing the same principle. record_create
rejects entirely when the limit is exceeded; bounded_copy partially accepts and reports the
state when the limit is exceeded. Two different responses show how the same source (the
caller-given limit) can determine two different outcomes. The before/diff arrangement
inside main is not needed here, because no observation depends on the previous one’s
counter state — both are measured independently with their own input and their own limit;
of the narrow and wide pointers, only wide is freed, because narrow is already NULL,
and record_destroy(NULL) safely does nothing.
The Bounding Measurement
Whether the limit and dest_size parameters are called with the correct value is not
something the compiler checks. Giving an excessively large limit disconnected from the real
size, like record_create(name, 1000000), also compiles; in that case the function still
applies its own internal check correctly, but the check becomes meaningless, because the
caller has misreported the real destination’s size. Bounds checking does not save anything if
the bound itself is wrong.
The same bound applies to bounded_copy‘s dest_size parameter as well. The sizeof small
expression works correctly here because small is a real array variable and the compiler
knows its size; but if sizeof were applied to a pointer parameter — for example if small
were taken as a function parameter written char *small — sizeof small would give the
pointer’s own size, not the array’s size, and this would silently produce a wrong
dest_size. This lesson does not fall into that trap because sizeof small and sizeof sufficient are called directly over the array definitions inside main, but the distinction
can be missed when carrying the pattern into another function; dest_size must always be
computed in the scope where the array is defined, not after it has decayed into a
pointer.
Even once the limit’s correctness has been established, the limit’s persistence is a
separate question. The limit given to record_create is valid only for that single call;
when the same Record is later passed to another function, the original limit value is not
carried along with it — only the pointer record_name returns and the copy record_name_copy
produces are carried along. This shows that the caller’s responsibility does not end with a
single call: the caller that first gives the limit must also document under what assumption
(for example “this name will be at most thirty-one characters”) it was given, because later
code can learn that assumption only by reading the documentation, not by reading it from the
source.
Summary
record_create’slimitparameter comes from the caller, not the source; the same name gave two different results with two different limits.snprintfprovided a copy that takes the destination’s size as a parameter, never exceeds it, and reports truncation through its return value;strcpynever provides this report at all.- When the same source was measured with two different destination sizes, the
neededlength stayed constant, only whether it was truncated changed — the limit’s correctness depends on the destination’s real capacity. - The memory this lesson measures is in the caller class: the party that knows and
reports the destination’s size is the call itself that gives the
limitordest_sizeparameter in the code. - These three lessons measured three separate classes in the course’s “who owns it” axis in sequence: the previous two lessons measured the unowned and callee classes through moments the contract broke and the function failed, this lesson measured the caller class by showing that the limit is written in the caller, not the source. What the three lessons share is that in none of them is the protecting party the language — protection each time comes from a line that a separate party added itself.
Next Step
These three lessons measured the moments the contract broke, the function failed, and the
caller gave a limit — all three ran on the same platform, the same compiler. This shared
assumption has never been questioned so far: every measurement produced the same result under
the same cc, the same flags, the same operating system. But even defined behavior itself is
not identical everywhere — part of it is defined by the standard, while which value it
takes can remain implementation-defined; even the width of size_t itself, the type of the
length bounded_copy returns as measured in this lesson, can vary from platform to platform.
The next lesson takes up how much this contract varies across platforms: which behavior
stays defined, which is implementation-defined, and how code keeps this portable. The three
examples in this lesson are therefore evidence of only one platform — even though the result
itself stays unchanged, which assumptions carry that result has not yet been written down
explicitly. That writing-down work begins without changing a single line of the lesson’s
code, only by changing the build environment.
To keep your progress and take notes, Log in
My notes
Log in to take notes.