Frontend Labs
⋯

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.


Generated from real-life-c-guide.md · 8 Oct 2026 16:36