45. Writing safe C: a defensive-programming checklist
This is the consolidated discipline behind every "GOOD" example in this guide — treat it as a pre-commit checklist for new C code, not a wall of theory to read once.
| # | Rule | Where this guide showed why |
|---|---|---|
| 1 | Check every return value that can fail (malloc, fopen, realloc, syscalls) before using the result |
Section 10.1, 12.3 |
| 2 | Bound every write into a fixed-size buffer — snprintf, not sprintf; strlcpy/manual length tracking, not strcpy/strcat |
Section 7.2, 29 |
| 3 | Never pass externally influenced data as a printf-family format string |
Section 46.4 below |
| 4 | Validate lengths from untrusted sources against both the source buffer's actual size and the destination's capacity, before any memcpy |
Section 29.3 |
| 5 | Give every heap allocation exactly one owner and one release path; use goto cleanup for multi-resource functions |
Section 4.2, 10.3, 44.1 |
| 6 | Null out or scope-limit pointers after free; never reuse a freed pointer |
Section 10.2, 28 |
| 7 | Use <stdint.h> exact-width types for anything with a size that matters (protocols, file formats); never assume int is 32 bits |
Section 3.1 |
| 8 | Prefer unsigned arithmetic with defined wraparound, or __builtin_*_overflow, over unchecked signed arithmetic where overflow is possible |
Section 3.3, 28 |
| 9 | Initialize every variable at its point of declaration; never read a value before it's assigned | Section 28, 46.9 |
| 10 | Never return a pointer to a local (stack) variable | Section 5.1, 46.7 |
| 11 | Compile with -Wall -Wextra -Wpedantic -Werror from the first commit, not added later |
Section 2.2, 46 (repeatedly) |
| 12 | Run the test suite under ASan/UBSan (and, on Linux, LeakSanitizer/Valgrind) before merging, not just before a release | Section 14, 44.6 |
| 13 | Keep mutable state static-private or instance-scoped; avoid file-scope globals a second thread or a re-entrant call path could race on |
Section 13.1, 20.1, 46.13 |
| 14 | Treat every value crossing a trust boundary (network, file, CLI args, environment) as hostile until validated | Section 29, 31 |
| 15 | For anything parsing untrusted input, fuzz it — a parser without a fuzz target is a parser you haven't tested against the inputs that will actually break it | Section 31 |
A worked example: applying the checklist to one function
// Reads a length-prefixed record from an untrusted socket buffer.
// Every numbered comment below maps to a checklist rule above.
int read_record(const unsigned char *data, size_t data_len,
char *out, size_t out_cap) {
if (data_len < 2) return -1; // (4) enough bytes for a length prefix?
uint16_t claimed_len;
memcpy(&claimed_len, data, sizeof claimed_len); // (7) exact-width type for a wire field
claimed_len = ntohs(claimed_len); // network byte order, always, for wire data
size_t remaining = data_len - 2;
if (claimed_len > remaining) return -1; // (4) trust neither side alone
if ((size_t)claimed_len >= out_cap) return -1; // (2) bound against the DESTINATION too
memcpy(out, data + 2, claimed_len);
out[claimed_len] = '\0';
return (int)claimed_len;
}
This is Section 29.3's example again, but read it now as a checklist walkthrough rather than a one-off fix: every line exists because of a specific rule above, not because "more checks are always better" — a defensively-written function should be able to point at why each check exists.