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.
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.
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:
src == NULL csak n == 0 mellett
megengedett.dst == NULL csak capacity == 0 mellett
megengedett.src legalább n olvasható, a nem
null dst legalább capacity írható C bájtot
jelöl.written egy írható size_t objektumot
jelöl, amely nem fedhet át egyik adatterülettel sem. A null
written kezelt argumentumhiba.n C bájt átmásolódik, és
*written == n.written
objektum nem változik.| 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.
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);
#endifcopy_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
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.
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.
Rögzített kapacitásnál, ha és ábrázolható, a jellegzetes hosszértékek:
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.
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-clangA header-próba deklarációs önállóságot vizsgál, nem implementációs helyességet vagy bináris kompatibilitást.
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.symbolsEz 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.
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.
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.
É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?
Egészítsd ki a tesztprogramot öt C bájtos forrással, négy C bájtos céllal és a hosszokkal. Ellenőrizd a státuszt, a számlálót és a teljes céltartalmat.
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?
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?
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.
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.
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.
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.
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.