11.2 Formázás és megbízható szövegfeldolgozás

A konfigurációs érték feldolgozása nem attól sikeres, hogy a bemenet elejéből előállt egy szám. A "443xyz" szövegnek van számmá alakítható kezdete, mégsem érvényes portszám. Megbízható feldolgozáskor külön vizsgáljuk a bemenet nyelvtanát, a konverzió sikerét, a reprezentálhatóságot, az alkalmazási tartományt és a teljes bemenet elfogyasztását.

A variadikus függvényeknél megismert típusszerződés itt közvetlen biztonsági követelmény. A stdio és pufferelés adja az I/O hátterét, a locale és szövegkódolás pedig megmagyarázza, miért nem feltétlenül környezetfüggetlen egy szöveges konverzió. Az alábbi nyelvi és könyvtári szabályok ISO C17-re vonatkoznak; a POSIX-kiegészítéseket külön jelöljük.

11.2.1 Formázás: a formátumsztring típusszerződés

A printf formátumsztringje meghatározza, milyen típusú argumentumot olvas ki a függvény. A variadikus argumentumokon végrehajtott alapértelmezett promóciókat figyelembe kell venni: például a float argumentum double lesz.

Kiírandó típus printf-formátum
int %d
unsigned int %u
long %ld
unsigned long long %llu
size_t %zu
double, illetve argumentumként átadott float %f, %e vagy %g
long double %Lf, %Le vagy %Lg
int32_t, ha rendelkezésre áll "%" PRId32

Az utolsó sorhoz a <stdint.h> és <inttypes.h> fejlécek tartoznak. Az exact-width típusok opcionálisak: például az int32_t elérhetőségét az INT32_MAX makró meglétével ellenőrizhetjük. Ha count ilyen típusú, a helyes alak:

/* ISO C17-részlet; <stdint.h>, <inttypes.h>, <stdio.h> mellett.
   Előfeltétel: int32_t elérhető, count inicializált int32_t. */
printf("count=%" PRId32 "\n", count);

A típusnévben szereplő szélesség nem határozza meg, hogy a típus int, long vagy más megfelelő egész típus aliasa. A hibás formátum–argumentum kapcsolat nem hordozható konverzió, hanem nem definiált viselkedést okozhat.

Külső szöveg soha ne legyen közvetlenül formátumsztring: printf("%s", text) használható, printf(text) nem megfelelő. Ettől még a sortörésekkel történő loginjektálás ellen külön védekezés kell.

11.2.1.1 A snprintf eredményének értelmezése

A snprintf(dst, capacity, ...) legfeljebb capacity C bájtot használ, a lezáró nullkarakterrel együtt. Pozitív kapacitásnál, érvényes célterület esetén nullterminál. A visszatérési érték:

Ezért a helyes ellenőrzési sorrend:

/* ISO C17-részlet; dst valódi, írható tömb, value típusa long. */
int n = snprintf(dst, sizeof dst, "value=%ld", value);
if (n < 0) {
    /* Kódolási hiba. */
} else if ((size_t)n >= sizeof dst) {
    /* Csonkolás: a teljes eredmény nem fért el. */
}

Előbb vizsgáljuk a negatív eredményt, csak utána konvertálunk size_t-ra. A sizeof dst itt tömbméret; pointerparaméteren nem a mögöttes tároló kapacitását adná.

11.2.2 Miért nem elég a scanf vagy az atoi?

A scanf-család visszatérési értéke a sikeresen hozzárendelt bemeneti elemek száma. A hozzárendelést elnyomó * és a %n nem növeli ezt a számot. Illesztési hibánál az eredmény lehet nulla; az első konverzió befejezése előtti bemeneti hibánál EOF.

A mezőszélesség kötelező védelem a sztringeknél. A %15s legfeljebb 15 karaktert tárol, majd nullkaraktert ír, tehát legalább 16 elemű char tömb kell hozzá. A %c ezzel szemben nem tesz lezáró nullkaraktert.

A szélességkorlát azonban nem bizonyítja a teljes mező elfogadását. Egy hosszabb token maradéka a bemenetben maradhat, vagy a következő konverzióhoz kerülhet. Két sikeres hozzárendelés sem bizonyítja, hogy nincs további szemét a sorban.

Numerikus bemenetnél súlyosabb korlát, hogy ha az eredmény nem reprezentálható a célobjektumban, a C17 scanf-szerződése szerint nem definiált viselkedés következhet be. A return count tehát nem általános túlcsordulás-ellenőrzés. Arra is figyeljünk, hogy itt %f float *, %lf pedig double * argumentumot vár.

Az atoi még kevesebb ellenőrzési lehetőséget ad: nincs végpointere, és a nulla eredményből nem állapítható meg a siker. A nem reprezentálható eredmény sem kezelhető megbízhatóan. Ellenőrizetlen konfigurációhoz használjunk strto... függvényt.

11.2.3 Ellenőrzött numerikus konverzió

A strtol, strtoull és strtod a bemenet elején álló megfelelő alakot dolgozza fel. Az endptr az első fel nem dolgozott karakterre mutat.

A teljes elfogadás feltétele:

siker=volt konverzió∧¬konverziós tartományhiba∧megengedett érték∧megengedett lezárás. \text{siker} = \text{volt konverzió} \land \neg\text{konverziós tartományhiba} \land \text{megengedett érték} \land \text{megengedett lezárás}.

A vizsgálat lépései:

  1. Alkalmazzuk a saját lexikai szabályainkat: engedünk-e szóközt, előjelet vagy alternatív számábrázolást?
  2. Állítsuk errno értékét nullára, majd végezzük el a konverziót.
  3. Az end == text feltétel azt jelzi, hogy nem történt konverzió.
  4. Az ERANGE konverziós tartományhibát jelez.
  5. Ellenőrizzük a lezárást és az alkalmazási értéktartományt.

Az errno itt csak a konverzióhoz kapcsolódó jelzés: sikeres hívás nem köteles nullára állítani. A végjel vizsgálatát nem helyettesíti.

11.2.3.1 Kidolgozott példa: portkonfigurációból normalizált log

A következő program egy port=443 alakú argumentumot fogad. A szerződés szándékosan szigorú: csak decimális számjegyek, nincs előjel vagy szóköz, a tartomány pedig 1…655351\ldots65535. Hiba esetén a kimeneti érték változatlan.

ISO C17, teljes program — parse_config.c:

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

enum parse_status {
    PARSE_OK,
    PARSE_SYNTAX,
    PARSE_OVERFLOW,
    PARSE_TRAILING,
    PARSE_DOMAIN
};

static enum parse_status
parse_port(const char *text, long *out)
{
    if (text[0] < '0' || text[0] > '9') {
        return PARSE_SYNTAX;
    }

    char *end;
    errno = 0;
    long value = strtol(text, &end, 10);
    int conversion_errno = errno;

    if (end == text) {
        return PARSE_SYNTAX;
    }
    if (conversion_errno == ERANGE) {
        return PARSE_OVERFLOW;
    }
    if (*end != '\0') {
        return PARSE_TRAILING;
    }
    if (value < 1 || value > 65535) {
        return PARSE_DOMAIN;
    }

    *out = value;
    return PARSE_OK;
}

int main(int argc, char **argv)
{
    if (argc != 2 || strncmp(argv[1], "port=", 5) != 0) {
        fputs("expected: port=DECIMAL\n", stderr);
        return EXIT_FAILURE;
    }

    long port = 0;
    enum parse_status status = parse_port(argv[1] + 5, &port);
    if (status != PARSE_OK) {
        fprintf(stderr, "invalid port: status=%d\n", (int)status);
        return EXIT_FAILURE;
    }

    char record[64];
    int n = snprintf(record, sizeof record,
                     "event=config port=%ld\n", port);
    if (n < 0 || (size_t)n >= sizeof record) {
        fputs("log formatting failed\n", stderr);
        return EXIT_FAILURE;
    }

    if (fputs(record, stdout) == EOF || fflush(stdout) == EOF) {
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

Reprodukáláshoz választott laborprofil: Linux, x86-64 System V ABI, LP64, glibc 2.40; GCC 14.2.0 vagy Clang 19.1.7. A következő parancsok fordítási útmutatók, nem itt elvégzett fordítás bizonyítékai:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wformat=2 \
    parse_config.c -o parse-config-gcc

clang -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wformat=2 \
    parse_config.c -o parse-config-clang

A várt besorolások:

Bemenet Eredmény
port=443 Siker
port=-1 Szintaktikai hiba
port=443xyz Hátramaradt bemenet
port=70000 Alkalmazási tartományhiba
port= Szintaktikai hiba
A LONG_MAX decimális alakja után még egy 0 Konverziós túlcsordulás

Ha több hiba is fennáll, a program ellenőrzési sorrendje határozza meg az elsődleges hibát: a túlcsordulás például megelőzi a hátramaradt bemenet jelentését.

Helyességi bizonyításvázlat. A kezdőkarakter-vizsgálat kizárja az előjelet és a kezdő szóközt. A tízes alapú konverzió és a nullkarakterre mutató végpointer együtt biztosítja a teljes számjegysor feldolgozását. A konverziós és alkalmazási tartományvizsgálat után az érték érvényes portszám. Az egyetlen kimeneti értékadás ezek után történik.

Objektum vagy hozzáférés Tulajdonos és élettartam Érvénytelenítés
argv[1], illetve text A programindítási környezet tárolója; a parser kölcsönzi A program végéig rendelkezésre áll
end Nem tulajdonló pointer ugyanabba a sztringbe Az alapul szolgáló tároló megszűnése
out A hívó írható long objektuma; itt port A hívó objektumának élettartamvége
record A main automatikus tömbje Újraíráskor a tartalom, kilépéskor a tároló

A parser előfeltétele érvényes nullterminált bemenet és érvényes, írható kimeneti objektum; pointereket nem őriz meg.

11.2.3.2 Előjel nélküli és lebegőpontos értékek

A strtoull elfogad mínuszjelet is: a "-1" eredménye lehet ULLONG_MAX tartományhiba nélkül. Ezért a „nemnegatív kvóta” szerződést nem biztosítja önmagában az előjel nélküli céltípus. Követeljünk kezdő számjegyet, használjunk tízes alapot, majd ellenőrizzük az ERANGE, a végpointer és az alkalmazási felső korlát feltételeit.

A strtod esetén az ERANGE túlcsordulást vagy alulcsordulást is jelezhet. A nulla eredmény önmagában nem hiba. A függvény végtelent, NaN-t és hexadecimális lebegőpontos alakot is elfogadhat; ezek engedélyezése külön döntés.

Egy véges, 0…36000\ldots3600 másodperces időköznél például:

/* ISO C17-részlet: <errno.h>, <math.h>, <stdlib.h>.
   text: érvényes, kölcsönzött nullterminált sztring.
   out: a hívó írható double objektuma.
   A lexikai szabályokat a hívó már ellenőrizte.
   A hibás ágakban out változatlan. */
char *end;
errno = 0;
double value = strtod(text, &end);
int conversion_errno = errno;

if (end == text) {
    /* Nincs szám. */
} else if (conversion_errno == ERANGE) {
    /* Konverziós tartományhiba. */
} else if (*end != '\0') {
    /* Részleges feldolgozás. */
} else if (!isfinite(value) || value < 0.0 || value > 3600.0) {
    /* Alkalmazási tartományhiba. */
} else {
    *out = value;
}

Ez a szemantikai ellenőrzés nem tiltja a hexadecimális alakot. Ha csak decimális protokollformátum engedélyezett, annak nyelvtanát külön ellenőrizni kell.

11.2.4 Teljes sor, karakterosztály és locale

ISO C17: az fgets legfeljebb a megadott kapacitásnál eggyel kevesebb karaktert olvas, megtartja a beolvasott sortörést és siker esetén nullterminál. A hiányzó sortörés nem bizonyít hibát: lehet hosszú sor részlete vagy az utolsó, sortörés nélküli sor. A teljes sorhatár eldöntéséhez további olvasás kellhet. Túl hosszú rekordnál a maradékot is kezeljük, különben a következő olvasás hamis új rekordot kap.

A NULL eredménynél ferror és feof alapján különítsük el a hibát és a bemenet végét. Az olvasási hibával érintett buffert ne dolgozzuk fel.

POSIX: a getline szükség szerint allokálja vagy növeli a hívó által kezelt buffert. Glibc alatt a deklarációhoz használható a fejlécek előtt definiált _POSIX_C_SOURCE=200809L. Visszatérési típusa ssize_t; siker esetén a hossz tartalmazza a beolvasott elválasztót, de nem a lezáró nullkaraktert. -1 jelzi a bemenet végét vagy hibát.

A buffer tulajdonosa a hívó: végül free szükséges, hibakimenet után is, ha történt allokáció. Új hívás felülírhatja a tartalmat, áthelyezés pedig érvényteleníti a korábbi belső pointereket. A korlátlan sorhossz memóriafogyasztási kockázat; szigorú erőforráskorláthoz korlátozott sorolvasó kell.

Beágyazott nullkarakter esetén a strlen nem a teljes beolvasott hosszt adja. A getline visszaadott hosszával ez felismerhető; az fgets-re épülő egyszerű sztringfeldolgozásnál a nullkarakter-mentesség legyen explicit bemeneti feltétel.

A <ctype.h> függvényeinek argumentuma csak EOF vagy unsigned char-ként reprezentálható érték lehet:

/* ISO C17-részlet, <ctype.h>; ch egy char érték. */
int whitespace = isspace((unsigned char)ch);

Egy negatív char közvetlen átadása UB lehet. A getchar eredményét viszont először int-ként, az EOF megőrzésével kezeljük.

A strtod tizedesjele a numerikus locale-tól függ. Gépi konfigurációnál rögzítsük a nyelvtant és a locale-feltételeket; könyvtári segédfüggvény ne módosítsa váratlanul a folyamat globális locale-ját. Ha csak ASCII szóköz és tabulátor megengedett, ezek explicit vizsgálata pontosabb szerződés, mint minden locale-függő whitespace elfogadása.

11.2.5 Feladatok

  1. Magyarázat: mi a hiba a size_t length érték %u formátumú kiírásában? Miért jelent csonkolást a snprintf esetén a kapacitással egyenlő eredmény?

  2. Kódolás: a portparser mintájára készíts parse_quota függvényt unsigned long long kimenettel és paraméterként kapott felső korláttal. A nulla legyen érvényes, a mínuszjel ne.

  3. Diagnosztika: egy logfeldolgozó %7s %u formátummal olvas char level[8] és unsigned int count objektumba. Két hozzárendelést teljes sikernek tekint. Sorolj fel három problémát, és javasolj javítást.

11.2.6 Megoldások

11.2.6.1 Formátum és kapacitás

A %u unsigned int argumentumot vár, a size_t nem szükségképpen ez a típus. A helyes formátum %zu.

Ha a visszaadott hossz nn, akkor a szükséges kapacitás:

Cszükséges=n+1, C_{\text{szükséges}} = n + 1,

mert a nullkarakter is helyet foglal. Ezért n=Cn=C már csonkolást jelent. Ellenőrzési feltétel: teljes sikerhez n >= 0 és (size_t)n < capacity kell.

11.2.6.2 Kvótaparser

A korábbi programhoz illeszthető ISO C17-függvényrészlet, azonos pointer- és hibaszerződéssel:

static enum parse_status
parse_quota(const char *text, unsigned long long limit,
            unsigned long long *out)
{
    if (text[0] < '0' || text[0] > '9')
        return PARSE_SYNTAX;

    char *end;
    errno = 0;
    unsigned long long value = strtoull(text, &end, 10);
    int conversion_errno = errno;

    if (end == text)
        return PARSE_SYNTAX;
    if (conversion_errno == ERANGE)
        return PARSE_OVERFLOW;
    if (*end != '\0')
        return PARSE_TRAILING;
    if (value > limit)
        return PARSE_DOMAIN;

    *out = value;
    return PARSE_OK;
}

Ellenőrzési feltétel: "0" és a korlát értéke sikeres; "-1" szintaktikai hiba; "12x" részleges feldolgozás; minden hibánál a korábban beállított kimenet változatlan.

11.2.6.3 Logfeldolgozó javítása

A hét karakternél hosszabb első mező maradéka a következő konverzióhoz kerülhet. A numerikus túlcsordulás nem kezelhető biztonságosan return counttal. Két sikeres hozzárendelés után további szemét is maradhat.

Először teljes, hosszkorlátozott rekordot olvassunk. Ezután különítsük el és ellenőrizzük a mezőhatárokat, a számot pedig strtoull és az UINT_MAX korlát segítségével dolgozzuk fel. Ellenőrzési eset legyen hosszú szintnév, túl nagy szám és érvényes rekord utáni extra token.

11.2.7 Összefoglaló

A formátumsztring típusszerződés, a buffer kapacitása tárolási szerződés, a parser pedig nyelvtani és értéktartomány-szerződés. Megbízható feldolgozás csak akkor történik, ha mindhárom teljesül. A „kaptam egy számot” helyett mindig azt bizonyítsuk: pontosan a megengedett teljes bemenetből kaptam megengedett értéket.