The Read That Cannot Be Bounded

Five chapters of reading input and not one string. Chapter 1 read a number with scanf("%d", &value), chapter 2 turned the count it returns into a loop condition, and everything since has read numbers that way. The obvious next step is scanf("%s", word), and the course has stepped around it on purpose, because as written it is a call that cannot be made safe. The next program is wrong on purpose.

#include <stdio.h>

int main(void)
{
    char word[8];

    if (scanf("%s", word) == 1)
    {
        printf("you typed %s\n", word);
    }

    return 0;
}

Everything visible about it is correct. The return value is checked exactly as chapter 2 taught, word is a real array with room for a terminator, and %s is the right specifier for a string. The fault is in what the argument list does not contain, and it is the same absence that sank strcpy last lesson: nothing in the call tells scanf how large word is. %s reads characters until it meets whitespace, however many that turns out to be, and writes all of them plus a terminator into the address you gave it. Feed the program extraordinarily and gcc still compiles it without a word of complaint, since the length is a property of the run and not of the source, so the only thing that speaks up is the run itself:

==13==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xfbffa8af0028 at pc 0xffffab40d034
WRITE of size 16 at 0xfbffa8af0028 thread T0
    #2 0xffffab431b64 in __isoc99_scanf (/usr/local/lib64/libasan.so.8+0xb1b64)
    #3 0x0000004008d8 in main /tmp/e.c:7
  This frame has 1 object(s):
    [32, 40) 'word' (line 5) <== Memory access at offset 40 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/e.c:7 in main

That is last lesson's report with a different function above your frame. The operation is a WRITE, its size is 16, the fifteen characters of extraordinarily and the terminator, and the destination is [32, 40), an object of eight bytes. The number 16 was chosen by whoever was typing, which is the whole problem in one sentence: with %s the person supplying the input decides how far past the end of your array the write goes. A field width does bound it, so scanf("%7s", word) stops after seven characters and is not a bug, but it still reads one whitespace-delimited word when what you almost always want is the line. Its ancestor gets could not be bounded at all and was removed from the language in C11, so never write it.

fgets Takes the Buffer and Its Size

The function that fixes this is fgets, and the fix is visible in the call. Read fgets(line, sizeof(line), stdin) aloud as three things: the buffer to fill, the size of that buffer, and where to read from, which is stdin for the keyboard or a pipe. The size is the argument scanf("%s", ...) had no way to accept, and sizeof(line) supplies it wherever line is a real array in scope. Given that size, fgets stops for one of three reasons: it has stored a newline, it has stored size - 1 characters, or the input ended. It writes a terminator in every one of those cases, so what lands in the buffer is always a valid string, and it returns the buffer it filled, or NULL when it stored nothing at all because the input had already run out. That NULL is the end signal, which makes while (fgets(line, sizeof(line), stdin) != NULL) the string version of chapter 2's while (scanf("%d", &value) == 1): the read is the condition, and the loop ends when there is nothing left to read.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char line[64];

    if (fgets(line, sizeof(line), stdin) != NULL)
    {
        printf("read [%s] length %zu, equals quit %d\n", line, strlen(line), strcmp(line, "quit") == 0);

        size_t length = strlen(line);

        if (length > 0 && line[length - 1] == '\n')
        {
            line[length - 1] = '\0';
        }

        printf("now  [%s] length %zu, equals quit %d\n", line, strlen(line), strcmp(line, "quit") == 0);
    }

    return 0;
}

Typing quit and pressing enter gives:

read [quit
] length 5, equals quit 0
now  [quit] length 4, equals quit 1

The first line of output is broken across two lines of the terminal, and that is the lesson, not a typo. When the whole line fits, fgets keeps the newline you typed. It is stored in the buffer like any other character, which is why the closing bracket lands underneath, why strlen reports 5 for a four-character word, and why strcmp(line, "quit") is not zero: the string is quit followed by \n, and that is a different string from quit. Nothing warns you. The comparison simply fails, or the printed value simply gains a blank line, and the cause is one invisible byte. So strip it deliberately, with the four lines above and nothing more clever: measure with strlen, check that the last character really is '\n' before touching it, and overwrite that one byte with '\0', which does not shorten the array but moves the end of the string back by one. The length > 0 test matters because the last line of a file often arrives with no newline at all, and an unconditional line[length - 1] = '\0' would eat a real character. You will also meet this written as line[strcspn(line, "\n")] = '\0', a one-line spelling that uses a search function this course does not cover; recognise it when you read it and keep writing the version above, which is built from parts you already know.

When the Line Does Not Fit

The other case is the one the size argument exists for. A buffer of eight bytes cannot hold a fifteen-character line, so what does fgets do with the rest?

#include <stdio.h>
#include <string.h>

int main(void)
{
    char line[8];
    int call = 1;

    while (fgets(line, sizeof(line), stdin) != NULL)
    {
        size_t length = strlen(line);

        printf("call %d read %zu characters, newline present %d\n", call, length, length > 0 && line[length - 1] == '\n');
        ++call;
    }

    return 0;
}

Given the single line abcdefghijklmno, that prints:

call 1 read 7 characters, newline present 0
call 2 read 7 characters, newline present 0
call 3 read 2 characters, newline present 1

When the line does not fit, fgets stores size - 1 characters, terminates them, and leaves the remainder in the input for the next call. Nothing is lost and nothing overflows: one typed line arrives as three strings, the first two seven characters long with no newline in them and the last holding the leftover o and the newline that ends it. That absent newline is also the signal, and it is why the strip is written as a test rather than an assumption. If your buffer is large enough for the lines you expect, this case never arises; if it is not, you find out by checking for the newline rather than by corrupting memory.

Reading Is Not Parsing

fgets gives you a line, which is rarely what you wanted; you wanted the two numbers on it. sscanf is the other half of the pair, and it is a function you already know, because sscanf is scanf aimed at a string instead of at the input stream. The format strings are the same, the & on the arguments is the same, and the return value is the same one chapter 2 built a loop out of: the number of conversions that succeeded.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char line[64];

    while (fgets(line, sizeof(line), stdin) != NULL)
    {
        size_t length = strlen(line);

        if (length > 0 && line[length - 1] == '\n')
        {
            line[length - 1] = '\0';
        }

        int left = 0;
        int right = 0;

        if (sscanf(line, "%d %d", &left, &right) == 2)
        {
            printf("%d + %d = %d\n", left, right, left + right);
        }
        else
        {
            printf("bad line: %s\n", line);
        }
    }

    return 0;
}

Given three lines of input, 3 4, then not numbers, then 10 20, it prints:

3 + 4 = 7
bad line: not numbers
10 + 20 = 30

Look at what the middle line did not do: it did not stop the program. The pair separates reading from parsing, and that separation is the reason to prefer it. fgets does the reading, bounded by a size and cut at a line boundary; sscanf does the parsing, on a string that is already safely in your hands, and its count says whether the line made sense. Raw scanf cannot recover this way, and chapter 2's loop ended at the first thing that would not convert because it had to: when scanf("%d %d", &left, &right) meets not numbers it converts nothing, returns 0, and leaves those characters exactly where they were, so calling it again meets the same text and returns 0 again, forever. The offending input is not consumed by the failure. With fgets the line is already out of the stream before parsing is attempted, so discarding it costs nothing, and the two good lines on either side of a bad one both get read. That is the pattern to keep: read whole lines with a size, strip the newline, parse with a checked count, and treat a bad line as a bad line rather than as the end of the input.

Key Takeaways

  • scanf("%s", buffer) is never told the buffer's size, so the person typing decides how many bytes get written. gcc cannot warn about it and AddressSanitizer reports a stack-buffer-overflow with a WRITE. A width such as %7s bounds the read but still takes a word rather than a line, and gets, which could not be bounded at all, was removed from the language in C11.
  • fgets(line, sizeof(line), stdin) takes the size the call was missing. It stops on a newline, on size - 1 characters, or at the end of input, always writes a terminator, and returns NULL when there was nothing left to read, which makes while (fgets(...) != NULL) the string counterpart of while (scanf("%d", &value) == 1).
  • When the line fits, fgets keeps the trailing newline. Strip it on purpose with size_t length = strlen(line); if (length > 0 && line[length - 1] == '\n') { line[length - 1] = '\0'; }. Left in place it silently breaks strcmp against a plain word and adds a blank line to printed output.
  • When the line does not fit, there is no newline and the remainder stays in the input for the next call, so one long line arrives as several strings. The missing newline is how you detect it.
  • sscanf is scanf reading from a string, with the same format strings and the same return value, the number of conversions completed, so == 2 is the check that two numbers were really there.
  • fgets plus sscanf separates the bounded read from the checked parse, which is what lets a program report one bad line and carry on. A failed scanf leaves the offending text in the stream and will meet it again on every retry.