10.5 C API-szerződések ellenőrzése

Egy C API helyessége nem merül ki abban, hogy néhány szokásos bemenetre megfelelő eredményt ad. A kliensnek azt is tudnia kell, mi történik üres adattal, elégtelen kapacitással vagy allokációs hibával; meddig érvényesek a kapott pointerek; és használható-e egy új könyvtárkiadás a korábban lefordított programmal.

Az ellenőrzés alapja ezért a szerződés, nem pusztán az implementáció. Az opaque handle és stabil interfész tulajdonlási szabályait tesztelhető állításokká alakítjuk. Az allokációs hiba-injektálás módszereire és a kétarchitektúrás layout-labor kompatibilitási megfigyeléseire építünk, részletes megismétlésük nélkül.

10.5.1 Mit jelent a szerződés ellenőrzése?

Az API-szerződés négy összetevője:

Fontos különbség van a kezelt hibás bemenet és az előfeltétel megsértése között. Egy dokumentáltan elutasított null pointer tesztelhető hibakimenet. Egy felszabadított objektumra mutató pointer nem feltétlenül az.

ISO C17: egy általános pointerértékből nem állapítható meg hordozhatóan, hogy mögötte megfelelő típusú, élő, elegendő méretű objektum található-e. A szerződésellenőrzés ezért nem jelent tetszőleges címek biztonságos kipróbálását.

Opaque handle esetén például:

Bemenet Lehetséges dokumentált szerződés Ellenőrzés
Élő handle A művelet végrehajtható Normál teszt
Null handle Argumentumhiba; destruktornál akár megengedett üres művelet Külön teszt mindkét műveletre
Már megsemmisített handle Előfeltétel-sértés Elkülönített diagnosztikai teszt, garantált hibakód nélkül
Más objektum címéből képzett handle Előfeltétel-sértés Nem része a normál funkcionális tesztkészletnek

A handle belsejében tárolt „magic number” nem oldja meg az utolsó két esetet: annak kiolvasásához előbb már hozzá kellene férni az objektumhoz.

10.5.2 Kidolgozott példa: átfedést engedő másolási API

Egy kis, allokációmentes másolási magon egyszerre ellenőrizhető a null-policy, a kapacitás, az aliasolás és a hibakimenet.

A szerződés:

Objektum vagy hozzáférés Tulajdonos Érvényesség és érvénytelenítés
Forrás- és céltároló Hívó A hívás alatt élő, megfelelően hozzáférhető tároló szükséges
src, dst Kölcsönzött hozzáférés A függvény nem őrzi meg; felszabadítás vagy tárolócsere érvénytelenítheti
written objektuma Hívó A teljes hívás alatt élő, írható, megfelelően igazított objektum
Hibakimenet Visszaadott státusz A már létező outputokat változatlanul hagyja

A hívás közben más végrehajtási szál nem módosíthatja a forrást, és nem végezhet ütköző hozzáférést a célhoz vagy a számlálóhoz.

10.5.2.1 Header és implementáció

A következő két fájl ISO C17-et igényel, fordítóbővítés és konkrét ABI-feltételezés nélkül.

copy_api.h:

#ifndef COPY_API_H
#define COPY_API_H

#include <stddef.h>

enum copy_status {
    COPY_OK = 0,
    COPY_ARGUMENT = 1,
    COPY_CAPACITY = 2
};

enum copy_status copy_bytes(
    void *dst, size_t capacity,
    const void *src, size_t n,
    size_t *written);

#endif

copy_api.c:

#include "copy_api.h"

#include <string.h>

enum copy_status copy_bytes(
    void *dst, size_t capacity,
    const void *src, size_t n,
    size_t *written)
{
    if (written == NULL ||
        (dst == NULL && capacity != 0) ||
        (src == NULL && n != 0)) {
        return COPY_ARGUMENT;
    }

    if (n > capacity) {
        return COPY_CAPACITY;
    }

    if (n != 0) {
        memmove(dst, src, n);
    }

    *written = n;
    return COPY_OK;
}

ISO C17: a memmove megengedi az átfedést, a memcpy nem. Az n != 0 ág miatt az üres esetben nem továbbítunk null pointert a könyvtári másolófüggvénynek.

Bizonyításvázlat. Minden hibával visszatérő ág megelőzi az első írást, ezért hibánál az output változatlan. Sikeres, nem üres másoláskor az ellenőrzésből n≤capacityn \leq capacity következik; a tárolók érvényességét az előfeltételek biztosítják. Az átfedést a memmove kezeli. Végül a számláló külön objektumba íródik, így nem rongálja a másolt adatot.

10.5.2.2 Teszteljük az állapotot is

A copy_test.c önálló tesztprogram. A feladatok később kiegészítik határértékekkel.

#include "copy_api.h"

#include <assert.h>
#include <string.h>

int main(void)
{
    unsigned char data[] = {10, 20, 30, 40};
    const unsigned char initial[] = {10, 20, 30, 40};
    const unsigned char shifted[] = {10, 10, 20, 30};
    size_t written = 91;

    assert(copy_bytes(data, 2, data, 3, &written)
           == COPY_CAPACITY);
    assert(written == 91);
    assert(memcmp(data, initial, sizeof data) == 0);

    assert(copy_bytes(data + 1, 3, data, 3, &written)
           == COPY_OK);
    assert(written == 3);
    assert(memcmp(data, shifted, sizeof data) == 0);

    assert(copy_bytes(NULL, 0, NULL, 0, &written)
           == COPY_OK);
    assert(written == 0);

    written = 91;
    assert(copy_bytes(data, sizeof data, NULL, 1, &written)
           == COPY_ARGUMENT);
    assert(written == 91);
    assert(memcmp(data, shifted, sizeof data) == 0);

    return 0;
}

Az ellenőrzendő eredmény a sikeres, assertion-hiba nélküli befejezés; ez nem közölt futtatási eredmény. A tesztet ne fordítsuk NDEBUG definícióval, mert akkor az assert kifejezései sem értékelődnek ki.

10.5.3 Határértékek és allokációs hibák

Rögzített cc kapacitásnál, ha c>0c>0 és c+1c+1 ábrázolható, a jellegzetes hosszértékek:

n∈{0,1,c−1,c,c+1}. n \in \{0,\;1,\;c-1,\;c,\;c+1\}.

A tesztforrásnak a legnagyobb vizsgált n értékhez is elegendő tárolót kell biztosítania. Egy apró tömb mellé megadott óriási hossz nem feltétlenül határértékteszt: sértheti a szerződést.

Az output megváltozását őrértékkel, a céltartalmat előzetes másolattal ellenőrizzük. Siker esetén a cél n utáni része is maradjon változatlan.

A másolási mag nem allokál. Egy ráépülő tulajdonló konstruktor viszont allokálhat handle-t, majd adattárolót. Tegyük fel, hogy nem üres bemenetre pontosan ezt a két allokációt végzi, és hibánál a kezdetben null kimeneti handle változatlan marad.

Injektált hiba Sikeres allokációk Visszatérésig felszabadított blokkok Kimeneti handle
Első allokáció 0 0 Null
Második allokáció 1 1 Null
Nincs hiba 2 0 Élő, tulajdonolt

Siker után a destruktornak mindkét blokkot fel kell szabadítania. A teszt tehát ne csak a hibakódot, hanem a ki nem szabadított blokkok számát és a kimeneti handle állapotát is ellenőrizze. Az injektálás számlálóit minden teszteset előtt alaphelyzetbe kell állítani.

10.5.4 Header és build ellenőrzése

A nyilvános header ne függjön attól, hogy a kliens előtte milyen más headert töltött be. Külön fordítási egységben, első includeként és ismételten is ellenőrizzük.

header_probe.c:

#include "copy_api.h"
#include "copy_api.h"

Választott, itt nem futtatott laborprofil: Linux x86-64, System V ABI, GCC 14.2.0 és Clang 19.1.7, glibc 2.39. Az alábbi parancsok az adott verziókra mutató fordítóneveket feltételezik:

gcc -std=c17 -Wall -Wextra -Wpedantic -Werror \
    -fsyntax-only header_probe.c

clang -std=c17 -Wall -Wextra -Wpedantic -Werror \
    -fsyntax-only header_probe.c

gcc -std=c17 -Wall -Wextra -Wpedantic -Werror \
    copy_api.c copy_test.c -o copy-test-gcc

clang -std=c17 -Wall -Wextra -Wpedantic -Werror \
    copy_api.c copy_test.c -o copy-test-clang

A header-próba deklarációs önállóságot vizsgál, nem implementációs helyességet vagy bináris kompatibilitást.

10.5.5 Forrás- és bináris kompatibilitás

Forráskompatibilitás esetén a korábbi kliensforrás módosítás nélkül használható az új headerrel és könyvtárral. A puszta sikeres fordítás még nem bizonyítja a viselkedési szerződés megőrzését.

Bináris kompatibilitás esetén a korábban lefordított kliens újrafordítás nélkül használható az új könyvtárral, a rögzített platform és ABI feltételei között.

Változtatás Forrásszintű következmény Bináris kockázat
Új, külön nevű függvény Rendszerint kompatibilis A régi exportok megőrzésével rendszerint kompatibilis
Privát opaque reprezentáció módosítása Nem látható a kliensnek A külső szerződés megtartásával kompatibilis lehet
Nyilvános struktúra bővítése Egyes kliensek tovább fordulnak Méret és tageltolás változhat
Függvény szignatúrájának módosítása Diagnosztika nélkül is átcsúszhat A változatlan szimbólumnév nem védelem
Hibakimenet szabályának módosítása Többnyire tovább fordul Layoutváltozás nélkül is viselkedési törés

Linux/ELF eszközvizsgálat: GNU binutils 2.42 nm eszközével a már elkészített régi és új könyvtár exportnevei összevethetők:

nm -D --defined-only old/libcopy.so |
    awk '{print $3}' | LC_ALL=C sort > old.symbols

nm -D --defined-only new/libcopy.so |
    awk '{print $3}' | LC_ALL=C sort > new.symbols

diff -u old.symbols new.symbols

Ez csak névlista-diff. Nem ellenőrzi a paramétertípusokat, a struktúralayoutot vagy az ownership-szabályokat. A readelf --dyn-syms --wide kimenetéből további szimbólumtulajdonságok vizsgálhatók.

A kompatibilitási tesztmátrix két külön esete:

Az első nem helyettesíti a másodikat.

10.5.6 Fuzzolható, külső mellékhatástól mentes mag

A „mellékhatásmentes mag” itt nem azt jelenti, hogy nincs outputírás. A függvénynek nincs fájl-, hálózati, környezeti vagy rejtett globális mellékhatása; hatása a dokumentált kimenetre korlátozódik.

A fuzzer bájtokból készítsen érvényes tárolókat és ellenőrzött hosszakat, ne nyers pointerértékeket. Tulajdonosa a tesztharness; a mag csak a hívás idejére kölcsönzi a tárolókat. Azok felszabadítása a visszatérés után történhet.

A másolási API hasznos tulajdonságai:

A sanitizer nem helyettesíti ezeket: memóriabiztonsági hiba nélkül is visszaadható rossz státusz vagy hibás tartalom.

10.5.7 Gyakori hibák

A leggyakoribb tesztelési hiány a hibakód ellenőrzése az output vizsgálata nélkül. Ugyanilyen veszélyes az azonos input és output cím tesztelése, miközben a részleges átfedés kimarad.

További hibaforrás, ha minden „érvénytelen pointerhez” hibakódot várunk, vagy a változatlan szimbólumlistát teljes ABI-bizonyítéknak tekintjük. A javítás mindkét esetben a vizsgált állítás és előfeltételeinek pontosítása.

10.5.8 Feladatok

10.5.8.1 Null és érvényesség

Érvényes written mellett mi történik, ha mindkét adatpointer null és mindkét méret nulla? Mi változik capacity == 1 esetén? Ugyanígy kezelhető-e egy felszabadított célterület?

10.5.8.2 Határértékteszt

Egészítsd ki a tesztprogramot öt C bájtos forrással, négy C bájtos céllal és a 0≤n≤50 \leq n \leq 5 hosszokkal. Ellenőrizd a státuszt, a számlálót és a teljes céltartalmat.

10.5.8.3 Allokációs diagnózis

A konstruktor második allokációjának hibája után null handle marad, de egy blokk továbbra is foglalt. Melyik szerződés sérült?

10.5.8.4 Kompatibilitási diagnózis

Egy érték szerint átadott nyilvános struktúra új mezőt kapott. Az exportlista változatlan, az újrafordított tesztek sikeresek. Mi hiányzik az ellenőrzésből?

10.5.9 Megoldások

10.5.9.1 Null és érvényesség

Az első hívás sikeres, és nullázza a számlálót. A második COPY_ARGUMENT eredményt ad, a számláló változatlan. A felszabadított tároló nem kezelt argumentumhiba: a szükséges élettartam hiányzik, ezért nincs garantált hibakimenet.

10.5.9.2 Határértékteszt

Az alábbi részlet a meglévő main függvénybe illeszthető:

const unsigned char src[5] = {1, 2, 3, 4, 5};

for (size_t n = 0; n <= sizeof src; ++n) {
    unsigned char dst[4] = {99, 99, 99, 99};
    size_t count = 91;

    enum copy_status status =
        copy_bytes(dst, sizeof dst, src, n, &count);

    assert(status == (n <= sizeof dst
                      ? COPY_OK : COPY_CAPACITY));
    assert(count == (n <= sizeof dst ? n : 91));

    for (size_t i = 0; i < sizeof dst; ++i) {
        unsigned char expected =
            (n <= sizeof dst && i < n) ? src[i] : 99;
        assert(dst[i] == expected);
    }
}

A forrás minden vizsgált hosszhoz elegendő. A teljes cél ellenőrzése a sikeres másolás utáni érintetlen részt is lefedi.

10.5.9.3 Allokációs diagnózis

A kimeneti szerződés teljesült, az erőforrás-szerződés nem: a részlegesen létrehozott handle tárolója kiszivárgott. A második hibautat úgy kell javítani, hogy a korábbi allokációt felszabadítsa. Ellenőrzési feltétel: visszatéréskor nulla élő új blokk.

10.5.9.4 Kompatibilitási diagnózis

Hiányzik a régi kliensbináris vizsgálata és a publikus layout összehasonlítása. A struktúra mérete, igazítása és ABI szerinti átadása is érintett lehet. Az újrafordítás éppen elfedi, hogy a régi kliens más reprezentációra készült.

10.5.10 Összefoglaló

A jó API-teszt a szerződés határait vizsgálja: null és üres bemenetet, kapacitást, átfedést, hibautáni állapotot és tulajdonlást. A header-próba, az allokációs hibateszt, a fuzzolható mag és a kompatibilitási mátrix különböző bizonyítékot ad. Egyik sem helyettesíti a többit, és egyik sem teszi biztonságossá az előfeltételeket megsértő hívást.