16.3 Unit test, property test és fuzzing

A parser tesztelésének kiindulópontja nem a kód elágazásainak száma, hanem az API szerződése: milyen bemenet érvényes, milyen eredmény tartozik hozzá, és mi marad változatlan hiba esetén? A bounds-ellenőrzött parsernél megfogalmazott invariánsokat most végrehajtható ellenőrzésekké alakítjuk. Az API-szerződések adják az elvárt viselkedést, a sanitizerek pedig további hibajelzőként működnek.

A cél három egymást kiegészítő módszer használata: konkrét példák unit tesztje, általános tulajdonságok ellenőrzése és új bemenetek automatizált keresése.

16.3.1 A szerződéstől a tesztig

A unit teszt egy elkülönített programrész viselkedését ellenőrzi ismert bemenet és elvárt eredmény alapján. A tulajdonságalapú teszt (property test) bemenetek egy családjára ellenőriz állítást. A fuzzing automatizált bemenet-előállítással keres hibát; a lefedettségvezérelt fuzzing (coverage-guided fuzzing) a végrehajtási visszajelzés alapján részesít előnyben új kódutakat elérő bemeneteket.

Mindhárom módszerhez kell tesztorákulum: szabály, amely eldönti, hogy a megfigyelt viselkedés helyes-e. Az összeomlás hiánya önmagában gyenge orákulum: egy parser biztonságosan visszaadhat hibás értéket is.

16.3.1.1 Egy kisméretű codec szerződése

A példaprotokoll két oktetten, big-endian sorrendben tárolja a payload hosszát, majd pontosan ennyi payload-oktett következik. A megengedett maximum 1024 oktett. A decoder a lezárt, teljes keretet kapja; csonka keretet és további adatot egyaránt elutasít.

Laborprofil: ISO C17; Linux x86-64, System V ABI, LP64, CHAR_BIT == 8. Választott eszközverziók: GCC 14.2.0, illetve Clang és compiler-rt 19.1.7, glibc 2.39. Ezek rögzített laborcélok, nem itt ellenőrzött futtatási eredmények; a telepített komponenseket és kapcsolókat helyben ellenőrizni kell.

A codec.h teljes tartalma:

#ifndef CODEC_H
#define CODEC_H

#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>

_Static_assert(CHAR_BIT == 8, "This codec requires octets.");

enum { FRAME_MAX = 1024 };

struct byte_view {
    const unsigned char *data;
    size_t size;
};

/* 0: siker; 1: elutasítás. Hiba esetén nincs kimeneti módosítás. */
static int codec_decode(const unsigned char *p, size_t n,
                        struct byte_view *out)
{
    if (n < 2)
        return 1;

    size_t len = ((size_t)p[0] << 8) | (size_t)p[1];

    if (len > FRAME_MAX || len != n - 2)
        return 1;

    *out = (struct byte_view){p + 2, len};
    return 0;
}

static int codec_encode(const unsigned char *p, size_t n,
                        unsigned char *dst, size_t cap,
                        size_t *written)
{
    if (n > FRAME_MAX || n > SIZE_MAX - 2 || cap < n + 2)
        return 1;

    dst[0] = (unsigned char)(n >> 8);
    dst[1] = (unsigned char)(n & 255u);
    if (n != 0)
        memcpy(dst + 2, p, n);

    *written = n + 2;
    return 0;
}

#endif

ISO C17-előfeltételek: a bemeneti pointerhez tartozó hossz ténylegesen olvasható tartományt jelöl; nulla hossznál a bemeneti pointer lehet null. A kimeneti paraméterek érvényes objektumokra mutatnak. Az encoder célterülete legalább cap C bájt írható tárolót jelöl, és nem fed át a bemenettel vagy a written objektummal. A decoder out objektuma sem fed át a bemenettel. Ezeket a pointerérvényességi feltételeket a függvények nem tudják általánosan ellenőrizni.

Objektum vagy hozzáférés Tulajdonos, élettartam és érvényesség
Bemeneti tároló A hívó tulajdona; a hívás alatt olvasható és változatlan
Encoder célterülete A hívó tulajdona; a hívás alatt írható
Decoder által visszaadott nézet Kölcsönzött hozzáférés; nem szabad felszabadítani
Nézet érvénytelenítése A bemeneti tároló felszabadítása, élettartamának vége vagy áthelyezése
Bemenet felülírása A nézet címe érvényes maradhat, de már nem az eredeti payloadot mutatja
Hibakimenet 1; a kimeneti objektumok és az encoder célterülete változatlanok

Üres payloadnál a nézet pointere lehet one-past érték; nem dereferálható.

16.3.1.2 Mérethatárok levezetése

Legyen nn a payload hossza, h=2h=2 a fejléc mérete, cc a célkapacitás. Sikeres kódoláshoz:

n≤1024,n≤SIZE_MAX−h,c≥n+h. n \le 1024,\qquad n \le \mathrm{SIZE\_MAX}-h,\qquad c \ge n+h.

Előbb az összeadás reprezentálhatóságát vizsgáljuk, és csak utána képezzük az összeget. A decoder hasonlóan előbb ellenőrzi az n≥2n\ge2 feltételt, majd kivonással számolja a tényleges payloadméretet.

Bizonyításvázlat. Az encoder második feltétele biztosítja, hogy n + 2 ne csorduljon körbe; a harmadik elegendő célterületet követel. A decoder első ellenőrzése után n - 2 nem csordul alul. A hosszmező és a tényleges payloadhossz egyenlősége egyszerre zárja ki a csonkolást és a záró többletadatot.

Itt az 1024-es korlát már önmagában kizárja az összeadási túlcsordulást. Az explicit ellenőrzés a méretképzés szabályát teszi láthatóvá.

16.3.2 Boundary tesztek és round-trip tulajdonságok

A határértékteszt (boundary test) a szerződés váltópontjait és azok közvetlen környezetét vizsgálja.

Eset Elvárt viselkedés
Teljes bemeneti hossz 0 vagy 1 Elutasítás: nincs teljes fejléc
Payloadhossz 0, 1, 1024 Siker megfelelő keretben
Payloadhossz 1025 Encoder-elutasítás
Érvényes keret bármely valódi prefixe Elutasítás: csonkolás
Érvényes keret egy további oktettel Elutasítás: többletadat
Szükségesnél eggyel kisebb célterület Elutasítás, változatlan kimenet
Reprezentálhatatlan méretösszeg Elutasítás még a memóriaelérés előtt

A következő test_codec.c megvalósítja a fő határteszteket:

#include "codec.h"
#include <assert.h>

int main(void)
{
    unsigned char payload[FRAME_MAX + 1] = {0};
    unsigned char frame[FRAME_MAX + 3] = {0};
    const size_t lengths[] = {0, 1, FRAME_MAX};

    for (size_t k = 0; k < sizeof lengths / sizeof lengths[0]; ++k) {
        size_t n = lengths[k];
        size_t used = 0;
        struct byte_view view = {NULL, 0};

        assert(codec_encode(payload, n, frame,
                            sizeof frame, &used) == 0);
        assert(used == n + 2);
        assert(codec_decode(frame, used, &view) == 0);
        assert(view.size == n);
        if (n != 0)
            assert(memcmp(view.data, payload, n) == 0);

        for (size_t cut = 0; cut < used; ++cut)
            assert(codec_decode(frame, cut, &view) == 1);

        assert(codec_decode(frame, used + 1, &view) == 1);

        used = 99;
        assert(codec_encode(payload, n, frame, n + 1, &used) == 1);
        assert(used == 99);
    }

    size_t used = 99;
    assert(codec_encode(payload, FRAME_MAX + 1, frame,
                        sizeof frame, &used) == 1);
    assert(codec_encode(payload, 0, frame, 0, &used) == 1);

    const unsigned char oversized[] = {0xff, 0xff};
    struct byte_view view = {NULL, 0};
    assert(codec_decode(oversized, sizeof oversized, &view) == 1);

    const unsigned char golden[] = {0x00, 0x01, 0x41};
    assert(codec_encode(golden + 2, 1, frame,
                        sizeof frame, &used) == 0);
    assert(used == sizeof golden);
    assert(memcmp(frame, golden, sizeof golden) == 0);
}

A bufferobjektumok tulajdonosa a main; a nézeteket csak ezen objektumok élettartamán belül használjuk. Az encoder újabb írása után a korábbi nézetből nem ellenőrizzük az előző tartalmat.

Jelölje EE az encodert, DD a decodert. Érvényes payloadra:

D(E(x))=x. D(E(x)) = x.

Ez értékegyezést jelent, nem pointerazonosságot. Kanonikus, elfogadott keretre ezenfelül:

E(D(b))=b. E(D(b)) = b.

A második tulajdonság nem általános minden formátumra: ha több reprezentáció ugyanazt az értéket jelenti, visszakódoláskor normalizált alak keletkezhet.

Gyakori hiba: kizárólag round-trip tesztre hagyatkozni. Két, egyformán rossz byte ordert használó függvény teljesítheti az első tulajdonságot. Ezért kell a független, ismert 00 01 41 referencia is.

16.3.3 Állapotmentes fuzz harness

A harness a fuzzert az API-hoz kapcsoló vékony adapter. Az alábbi fuzz_codec.c LLVM libFuzzer-specifikus belépési pontot használ; maga a codec továbbra is C17.

#include "codec.h"
#include <stdlib.h>

static void require(int condition)
{
    if (!condition)
        abort();
}

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
    if (size > FRAME_MAX + 2u)
        return 0;

    unsigned char encoded[FRAME_MAX + 2];
    size_t used = 0;
    struct byte_view a = {NULL, 0};
    struct byte_view b = {NULL, 0};

    /* A tetszőleges bemenetet payloadként is vizsgáljuk. */
    if (size <= FRAME_MAX) {
        require(codec_encode(data, size, encoded,
                             sizeof encoded, &used) == 0);
        require(codec_decode(encoded, used, &a) == 0);
        require(a.size == size);
        if (size != 0)
            require(memcmp(a.data, data, size) == 0);
    }

    /* Ugyanazt a bemenetet teljes keretként is vizsgáljuk. */
    int first = codec_decode(data, size, &a);
    int second = codec_decode(data, size, &b);
    require(first == second);

    if (first == 0) {
        require(a.size == b.size);
        if (a.size != 0)
            require(memcmp(a.data, b.data, a.size) == 0);

        require(codec_encode(a.data, a.size, encoded,
                             sizeof encoded, &used) == 0);
        require(used == size);
        require(memcmp(encoded, data, size) == 0);
    }

    return 0;
}

A data tárolójának tulajdonosa a fuzzing runtime; csak a callback alatt kölcsönözzük. Az encoded automatikus tárolási idejű helyi tömb. Egyetlen pointer sem kerül globális állapotba, és egyik nézetet sem használjuk tárolójának megszűnése után. Szabálytalan protokolladatnál normális visszatérés történik; tulajdonságsértésnél abort() állítja meg a futást.

A harness nem használ órát, hálózatot vagy véletlen állapotot. Az azonos inputra kétszer végzett decode a determinisztikusság egy ellenőrzése, nem teljes bizonyítása. Állapotos modulnál minden hívásra új kontextust kell létrehozni, majd lezárni.

16.3.3.1 Fordítás, corpus és erőforráskorlát

A fordítási és futtatási parancsok külön tanulói laborhoz tartoznak:

gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -UNDEBUG \
    test_codec.c -o test_codec

clang -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
    -fno-omit-frame-pointer \
    -fsanitize=fuzzer,address,undefined \
    -fno-sanitize-recover=undefined \
    fuzz_codec.c -o fuzz_codec

./test_codec

mkdir -p corpus artifacts
./fuzz_codec corpus -artifact_prefix=artifacts/ \
    -max_len=1026 -timeout=2 -rss_limit_mb=512 -max_total_time=60

A -UNDEBUG biztosítja, hogy a unit tesztek assert ellenőrzései ne tűnjenek el. A harness require függvénye ettől független.

A corpus a megőrzött bemenetek halmaza. Hasznos kezdőminták az üres payload kerete, egy egyoktettes payload, maximális érvényes keret és csonkolt változataik. A fuzzer ezek mutálásával keres további lefedettséget. Az ASan és az UBSan a végrehajtott útvonalakon további hibákat jelezhet; a szemantikailag hibás, de memóriabiztos eredményt továbbra is a tulajdonságellenőrzés találja meg.

A korlátok külön feladatot látnak el:

A fuzzer korlátai mellé CI-szintű folyamat- és erőforrásfelügyelet is kell. A harness méretellenőrzése nem akadályozza meg egy túlméretes corpusfájl korábbi betöltését, ezért a corpus fájlméreteit is ellenőrizni kell.

Ez a codec nem rekurzív. Beágyazott formátumnál explicit mélységkeret szükséges, például legfeljebb 32 egymásba ágyazott elem; a következő belépés előtt kell elutasítani. A mélységkorlát mellett teljes munkakeret is indokolt lehet, mert a kis mélység nem zár ki nagyon széles struktúrát.

16.3.4 Minimalizálás és regresszió

Két külön műveletet nevezzünk meg:

LLVM libFuzzer-parancspéldák; az artifacts/crash-ID helyére a tényleges hibafájl kerül:

mkdir -p corpus_small
./fuzz_codec -merge=1 corpus_small corpus

./fuzz_codec -minimize_crash=1 \
    -exact_artifact_path=minimized.bin artifacts/crash-ID

A minimalizálás nem garantál globálisan legkisebb inputot. Ellenőrizni kell azt is, hogy a kisebb bemenet ugyanazt a gyökérokot éri-e el, nem egy másik hibát.

A javítás után az input kerüljön regressziós tesztbe. Ne csak az legyen az elvárás, hogy „nem omlik össze”: rögzítsük a visszatérési értéket, a kimenet tartalmát és hiba esetén annak változatlanságát.

16.3.5 Feladatok

  1. Magyarázó: miért nem elég a decode(encode(x)) == x tulajdonság? Adj olyan közös encoder–decoder hibát, amelyet nem talál meg.

  2. Kódolási: egészítsd ki a unit tesztet úgy, hogy elégtelen célkapacitásnál a teljes célbuffer és a written is bizonyíthatóan változatlan maradjon.

  3. Diagnosztikai: feltételezzük, hogy a decoder első ellenőrzése tévesen n < 1. Miért érdekes az egyetlen nulla oktettből álló input? Írj regressziós tesztet a helyes viselkedésre.

16.3.6 Megoldások

16.3.6.1 Magyarázó feladat

Ha mindkét függvény little-endian hosszmezőt használ, egymással konzisztensen működhetnek, miközben megsértik a protokollt. Független referencia szükséges. Ellenőrzési feltétel: az egyoktettes 0x41 payload kódolása pontosan 00 01 41.

16.3.6.2 Kódolási feladat

A következő C17-részlet a test_codec.c main függvényébe illeszthető:

unsigned char dst[4];
unsigned char before[4];
const unsigned char src[] = {0x41};
size_t written = 77;

memset(dst, 0xa5, sizeof dst);
memcpy(before, dst, sizeof dst);

assert(codec_encode(src, sizeof src, dst, 2, &written) == 1);
assert(written == 77);
assert(memcmp(dst, before, sizeof dst) == 0);

Három oktett kellene, de csak kettőt engedélyezünk. A helyi tömbök tulajdonosa a tesztfüggvény; nincs átfedés vagy kimenekített nézet. A visszatérési érték és mindkét változatlansági feltétel együtt alkotja az orákulumot.

16.3.6.3 Diagnosztikai feladat

A hibás ellenőrzés átengedi az egyoktettes bemenetet, majd a kód kiolvassa p[1] értékét a tömbön kívül. Ez UB; nincs garantált kimenete. A javított változat regressziós tesztje:

const unsigned char one[] = {0};
struct byte_view result = {NULL, 77};

assert(codec_decode(one, sizeof one, &result) == 1);
assert(result.data == NULL && result.size == 77);

A tesztet a korábbi unit tesztbe illesztjük. Elvárt eredménye az elutasítás és a kimenet változatlansága; sanitizeres futtatásban emellett nem várunk memóriahibajelzést.

16.3.7 Összefoglaló

A meaningful teszt a szerződésből származik: mérethatárt, értékazonosságot, kanonikus reprezentációt vagy hibagaranciát ellenőriz. A határtesztek célzottak, a property tesztek bemenetcsaládokat fednek le, a coverage-guided fuzzing pedig új végrehajtási utakat keres. Az izolált harness korlátos erőforrással dolgozik, a minimalizált hibából pedig pontos elvárást rögzítő regressziós teszt készül. Egyik hibamentes futás sem helyességigazolás; együtt azonban lényegesen erősebb ellenőrzési rendszert alkotnak.