Strings and char arrays¶
C has no built-in string type. Every piece of text — a cargo ID, a type tag, a UN number — is a raw array of bytes terminated by a special zero byte. Understanding this model is mandatory for reading CargoForge-C's parser, which processes untrusted text line by line and must never let a malformed manifest corrupt memory.
The mental model 🧠¶
A C string is a row of mailboxes with no "end of street" sign — except a single empty mailbox, the NUL byte '\0'. Every string function (strlen, printf, strcmp) walks down the row reading boxes until it hits that empty one. Take the stop sign away and the function doesn't stop — it keeps walking into the neighbours' mail, reading whatever memory happens to sit after your text.
A char id[32] is a block of 32 mailboxes reserved inside the Cargo struct: 31 usable characters plus the mandatory empty one. No heap allocation, no separate bookkeeping — the text lives right in the form. The danger is a copy that fills all 32 boxes and forgets to leave one empty, which is exactly why parse_cargo_list always follows strncpy(c->id, src, 31) by hand-writing c->id[31] = '\0'. strncpy won't place the stop sign for you when the source fills the buffer exactly.
The rule to hold: in C, text is just bytes, and the only thing marking "the end" is one zero byte you are responsible for. Forget it and the bug isn't a wrong string — it's memory corruption three functions away.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- NUL terminator (
'\0') = "the stop sign at the end of every string" — it's the single zero byte that tellsstrlen,printf, and every other C string function where your text ends; without it, they keep reading into whatever memory comes next. - Fixed-size char array (
char id[32]) = "a reserved parking lot with exactly 32 spaces" —Cargouses these foridandtypeso the text lives right inside the struct without a separate heap allocation; 31 usable characters is enough for any realistic cargo label. strncpy+ explicit terminator = "copy at most N bytes, then force-write the stop sign yourself" —parse_cargo_listalways followsstrncpy(c->id, id, sizeof(c->id) - 1)withc->id[sizeof(c->id) - 1] = '\0'becausestrncpywon't write the terminator if the source fills the buffer exactly.strtok_r= "a whitespace splitter that remembers where it left off, per caller" —parse_cargo_listuses two independentsaveptrvariables so the outer manifest-field loop and the inner"x"-delimited dimension loop can both run at the same time without stepping on each other.- Copy before
strtok_r= "give the slicer its own scratch copy so it doesn't destroy read-only memory" —parse_dg_fieldcopies the DG field into a localbuf[64]before tokenising becausestrtok_rwrites'\0'bytes into the buffer it works on, and mutating aconst char *is undefined behaviour. - Use-after-free = "reading from an address after you've already handed that memory back" — one of the three classic C-string bug shapes; CargoForge-C defends against it by setting
ship->cargo = NULLimmediately afterfree, so a stale pointer can't silently re-read freed data (documented indocs/BUG_JOURNAL.md).
Why it matters: every C-string bug — buffer overrun, missing terminator, stale pointer — is invisible at compile time and silently corrupts memory at runtime, so a single malformed manifest line could corrupt the ship's Cargo array or leak the bytes of a neighbouring struct field; the strncpy-plus-explicit-terminator pattern and the copy-before-strtok_r discipline are what keep parse_cargo_list and parse_dg_field safe against untrusted input.
What a string actually is in C¶
In C, a string is a char array whose last meaningful byte is the NUL terminator '\0' (ASCII value 0). Every standard library function that works on strings (strlen, strcmp, strcpy, strncpy, strtok_r) relies on finding this zero byte to know where the string ends.
The array occupies exactly 32 bytes in memory regardless of how much of it is used. If you store the five-character string "CONT1", bytes 0–4 hold 'C','O','N','T','1', byte 5 holds '\0', and bytes 6–31 are irrelevant (but still allocated).
What happens without '\0'
If you fill all 32 bytes with non-zero characters and then call strlen or printf("%s", ...), the function keeps reading past the array boundary, scanning the next variable on the stack (or heap) until it happens to find a zero byte. This is a buffer over-read — undefined behaviour that can leak data or crash the program.
Fixed-size char arrays in the Cargo struct¶
The Cargo struct (defined in cargoforge.h) uses fixed-size arrays for the two text fields every cargo item carries:
| Field | Type | Max visible chars |
|---|---|---|
id |
char[32] |
31 |
type |
char[16] |
15 |
The sizes are chosen to be generous for realistic data ("HeavyMachinery" is 14 characters; "hazardous" is 9) while keeping the struct small enough to fit in a cache line (a small, extremely fast chunk of memory the CPU pulls in all at once — a smaller struct means fewer of these fetches to read the whole thing) alongside the float fields. Heap allocation would be wasteful for strings this short and would add a pointer-lifetime problem for every copy of the struct.
Reading text from a line: strtok_r¶
The cargo manifest is whitespace-delimited:
parse_cargo_list in src/parser.c tokenises each line with strtok_r:
char *saveptr;
char *id = strtok_r(line, " \t", &saveptr);
char *w_str = strtok_r(NULL, " \t", &saveptr);
char *dim_str= strtok_r(NULL, " \t", &saveptr);
char *type = strtok_r(NULL, " \t\n", &saveptr);
How strtok_r works¶
strtok_r(str, delimiters, &saveptr) scans str for the next token separated by any character in delimiters. On the first call you pass the string; on subsequent calls you pass NULL and the function resumes from where it left off, using saveptr to remember its position between calls.
Crucially, strtok_r modifies the input buffer in place: it replaces each delimiter it finds with a '\0', so the returned pointer points directly into the original line array. No heap allocation happens. The _r suffix means "re-entrant" — saveptr is caller-supplied state, so two concurrent tokenisation loops (such as the outer manifest loop and the inner dimension loop) can run without interfering.
Why not plain strtok?
The original strtok stores its position in a hidden global variable. The dimension parser inside the same function needs its own independent position (dim_saveptr). Using plain strtok for both would cause the outer loop to lose its place when the inner dimension loop runs. strtok_r avoids this by making the state explicit.
The dimension field "20x5x3" is tokenised with a second, nested strtok_r using "x" as delimiter:
char *dim_saveptr;
char *tok = strtok_r(dim_str, "x", &dim_saveptr);
for (int d = 0; d < MAX_DIMENSION; ++d) {
if (!tok) { dims_ok = false; break; }
float dv = safe_atof(tok, 0.1f, 1e4f, "dimension");
if (isnan(dv)) { dims_ok = false; break; }
c->dimensions[d] = dv;
tok = strtok_r(NULL, "x", &dim_saveptr);
}
Two independent saveptr variables keep the two tokenisation loops completely separate.
Copying into fixed buffers: strncpy and the explicit guard¶
Once the tokens are extracted, they are copied into the Cargo struct's fixed arrays. The parser does this in two lines for each field (from src/parser.c):
strncpy(c->id, id, sizeof(c->id) - 1);
c->id[sizeof(c->id) - 1] = '\0';
strncpy(c->type, type, sizeof(c->type) - 1);
c->type[sizeof(c->type) - 1] = '\0';
Why strncpy, not strcpy?¶
strcpy(dst, src) copies bytes until it hits '\0' in src, writing as many bytes as the source string requires — with no regard for how large dst is. If a user passes a 200-character cargo ID, strcpy silently overwrites the 168 bytes after c->id, corrupting whatever struct fields follow it in memory.
strncpy(dst, src, n) copies at most n bytes. The -1 argument (sizeof(c->id) - 1) leaves room for the terminator. But there is a subtle trap: if src is exactly n bytes long with no '\0' in the first n characters, strncpy fills dst with n non-zero bytes and writes no terminator. The explicit assignment on the next line patches that case:
This forces a terminator into the very last byte of the array regardless of what strncpy did. The pattern is so common in C systems code that it is worth memorising as a unit:
Use sizeof, not a literal
Writing strncpy(c->id, id, 31) is fragile — if the struct field is ever resized, the magic number silently goes stale. sizeof(c->id) - 1 tracks the actual array size automatically.
Parsing the DG field: a second strtok_r over a copy¶
The optional DG field in the manifest has its own colon-delimited grammar:
parse_dg_field in src/parser.c handles this entirely within a 64-byte stack buffer so it never touches the caller's memory:
static DGInfo *parse_dg_field(const char *field) {
if (!field || strncmp(field, "DG:", 3) != 0)
return NULL;
DGInfo *dg = calloc(1, sizeof(DGInfo));
if (!dg) return NULL;
/* Work on a copy */
char buf[64];
strncpy(buf, field + 3, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
char *saveptr;
char *tok = strtok_r(buf, ":", &saveptr);
...
Several design decisions are packed into those eight lines (including calloc,
which allocates memory on the heap and zeroes it in one step — Lesson 11
covers heap allocation properly):
-
strncmp(field, "DG:", 3)— checks the three-character prefix without requiring the whole string to match. If the prefix is absent the function returnsNULLimmediately, so a non-DG manifest field causes no allocation. -
field + 3— pointer arithmetic.fieldpoints to the start of"DG:3.1:UN1203:...". Adding 3 advances the pointer past the prefix to"3.1:UN1203:...". No copy of the prefix is needed. -
Copy into
bufbefore callingstrtok_r—strtok_rmutates the buffer it works on (replacing:with'\0'). The originalfieldpointer isconst char *— writing to aconststring is undefined behaviour and will crash if the string lives in read-only memory (as string literals often do). Copying to a localbuffirst givesstrtok_rwritable memory to modify. -
buf[sizeof(buf) - 1] = '\0'— the explicit terminator guard afterstrncpy, for the same reason shown above.
The UN number and EmS string are then copied into dg->un_number and dg->ems with the same strncpy + explicit terminator pattern:
tok = strtok_r(NULL, ":", &saveptr);
if (tok) {
strncpy(dg->un_number, tok, sizeof(dg->un_number) - 1);
}
Note that no explicit terminator assignment appears here — calloc zeroed the entire DGInfo struct on allocation, so dg->un_number is already filled with '\0' bytes before strncpy runs. Both approaches (explicit assignment and rely-on-calloc) are correct; the explicit assignment is more defensive.
Why C strings are dangerous: a mental model¶
Every bug involving C strings follows one of three shapes:
| Shape | What happens | Example trigger |
|---|---|---|
| Over-read | Reading past '\0' because no terminator was written |
strncpy without explicit '\0' on a full buffer |
| Over-write | Writing past the end of the array | strcpy with a longer source |
| Use-after-free | Pointer to freed memory still read | Stale char * after free(buf) |
CargoForge-C's parser defends against all three:
- Fixed arrays + strncpy + explicit terminator → no over-read or over-write.
- Copy-to-stack-buffer before strtok_r → no mutation of const or shared memory.
- ship->cargo = NULL after free in error paths → no dangling pointer (see the bug journal entry in docs/BUG_JOURNAL.md for the exact heap-use-after-free this fixed).
The ship-config path: strncpy for file paths¶
parse_ship_config uses the same pattern for optional file paths:
char hydro_path[256] = {0};
char tanks_path[256] = {0};
...
if (strcmp(key, "hydrostatic_table") == 0) {
strncpy(hydro_path, value, sizeof(hydro_path) - 1);
continue;
}
The = {0} initialiser zero-fills the entire 256-byte array at declaration. This means hydro_path[0] == '\0' until a value is parsed, which lets the check if (hydro_path[0] != '\0') later in the function detect whether the key was actually present — a clean sentinel that avoids a separate boolean flag.
Recap¶
- A C string is a
chararray terminated by'\0'. Standard library functions find the end by scanning for this byte; a missing terminator causes them to read past the array boundary. Cargostoresid(char[32]) andtype(char[16]) as fixed-size arrays — enough for real data, small enough to live inline in the struct.strncpy(dst, src, sizeof(dst) - 1)followed bydst[sizeof(dst) - 1] = '\0'is the canonical safe copy idiom; the explicit assignment handles the case wheresrcis exactly as long as the buffer.strtok_rtokenises a mutable buffer in place, using a caller-suppliedsaveptrso two independent tokenisation loops (manifest fields and dimension components) can run without interfering.- Always copy a
const char *to a local buffer before passing it tostrtok_r— the function writes'\0'bytes into the buffer, and writing to aconstor read-only string is undefined behaviour. - Zero-initialising a char array with
= {0}gives a free empty-string sentinel without a separate boolean flag.
Check yourself¶
If strncpy's source string fills the destination buffer exactly to its size, does it still write the NUL terminator?
No — strncpy only pads with NUL bytes if the source is shorter than the destination; if it fills the buffer exactly, no terminator is written. That is why parse_cargo_list always follows strncpy with an explicit buf[sizeof(buf)-1] = '\0'.
Why does parse_dg_field copy the DG field into a local buffer before calling strtok_r on it?
strtok_r writes NUL bytes into the string it tokenises as it works. Mutating a const char * — such as a pointer straight into the original input line — would be undefined behaviour, so the field is copied into its own mutable scratch buffer first.