הוכחת איכות ספרייה¶
נניח שכתבנו parser שמקבל מספר עשרוני. ניסינו את המחרוזת "123", קיבלנו מאה עשרים ושלוש, והבדיקה עברה. בפועל עוד לא בדקנו קלט ריק, תו שאינו ספרה, גלישה, אורך עצום או מצביעים לחלק ממחרוזת. בדיקה אחת מאשרת תצפית אחת. כדי לבנות ספרייה אמינה צריך להתחיל מחוזה ואז לבחור ניסויים שמסוגלים להפר אותו.
בפרק הזה כתבנו טיפוסים שאמורים לשמור על בעלות, טווחים שמסתמכים על חיי מקור ומשאבי זיכרון שעלולים להיכשל. לכל אחד מהם מתאים סוג אחר של בדיקה. אין כלי יחיד שמוכיח את כל הדרישות.
מתחילים בחוזה קטן שאפשר לתקוף¶
נבנה parser עם שלושה מצבי כשל מפורשים. הקוד הבא הוא תוכנית מלאה ב־C++20.
#include <cassert>
#include <cstdint>
#include <limits>
#include <string_view>
#include <variant>
enum class ParseError { empty, invalid_digit, overflow };
using Result = std::variant<std::uint32_t, ParseError>;
Result parse_u32(std::string_view text) {
if (text.empty()) return ParseError::empty;
std::uint32_t value = 0;
constexpr auto maximum =
std::numeric_limits<std::uint32_t>::max();
for (char c : text) {
if (c < '0' || c > '9')
return ParseError::invalid_digit;
const auto digit = static_cast<std::uint32_t>(c - '0');
if (value > (maximum - digit) / 10)
return ParseError::overflow;
value = value * 10 + digit;
}
return value;
}
int main() {
assert(std::get<std::uint32_t>(parse_u32("0")) == 0);
assert(std::get<std::uint32_t>(parse_u32("4294967295")) ==
std::numeric_limits<std::uint32_t>::max());
assert(std::get<ParseError>(parse_u32("")) == ParseError::empty);
assert(std::get<ParseError>(parse_u32("12x")) ==
ParseError::invalid_digit);
assert(std::get<ParseError>(parse_u32("4294967296")) ==
ParseError::overflow);
}
הבדיקה של הגלישה מתרחשת לפני הכפל והחיבור. החיסור בטוח כי הספרה נמצאת בין אפס לתשע. בחרנו להחזיר את השגיאה הראשונה שנפגשת בסריקה; למשל קלט שכבר גולש ואז מכיל אות יוחזר כגלישה. גם סדר כזה הוא חלק מהחוזה, אם לקוחות מסתמכים עליו.
הקוד אינו מקצה זיכרון ואינו שומר את התצוגה אחרי החזרה. לכן אין צורך לתכנן לו מנגנון allocation failure. לעומת זאת, עדיין צריך להוכיח שה־string_view עצמו מצביע לטווח חי במהלך הקריאה. בדיקה של תוכן אינה מתקנת מצביע תלוי שהמשתמש העביר.
תכונה בודקת משפחה של מקרים¶
תכונה שימושית היא שהמרה ממספר למחרוזת וחזרה משמרת את המספר. אפשר לבדוק אותה על הרבה מספרים, ובמיוחד על גבולות שבהם מספר הספרות משתנה.
#include <charconv>
#include <array>
#include <system_error>
void round_trip(std::uint32_t value) {
std::array<char, 10> storage{};
const auto converted =
std::to_chars(storage.data(), storage.data() + storage.size(), value);
assert(converted.ec == std::errc{});
auto parsed = parse_u32(std::string_view(
storage.data(), static_cast<std::size_t>(converted.ptr - storage.data())));
assert(std::holds_alternative<std::uint32_t>(parsed));
assert(std::get<std::uint32_t>(parsed) == value);
}
הפונקציה מצטרפת לתוכנית הקודמת. מוסיפים קריאות עם אפס, תשע, עשר, תשעים ותשע והערך המרבי. אפשר להוסיף סדרת מספרים פסאודו־אקראיים עם seed קבוע, כדי שניתן יהיה לשחזר כשל. בדיקה אקראית בלי שמירת seed וקלט מכשיל משאירה אותנו עם סיפור במקום עם תחקיר.
התכונה אינה הוכחה מלאה. גם הממיר וגם ה־parser יכולים לחלוק טעות מסוימת, או שהדגימה יכולה לפספס גבול. לכן משלבים דוגמאות ידועות, תכונות, ניתוח אריתמטי וסקירת קוד.
מכניסים קלט שאיש לא תכנן¶
בדיקת fuzzing מחפשת מסלולים שהמחבר לא חשב לכתוב ביד. היעד מקבל bytes ואורך, בונה מהם תצוגה מוגבלת ומפעיל את ה־parser. במנוע מבוסס coverage, שינויי קלט נשמרים כאשר הם מגיעים למסלול חדש.
החיבור למנוע מסוים תלוי במהדר ובכלים שהותקנו. הקטע הבא הוא גוף יעד עבור libFuzzer בסביבה שתומכת בו, ולא תוכנית שמקמפלים כ־main רגיל.
#include <cstddef>
#include <cstdint>
extern "C" int LLVMFuzzerTestOneInput(
const std::uint8_t* data, std::size_t size) {
if (size > 4096) return 0;
if (size == 0) {
(void)parse_u32({});
return 0;
}
auto text = std::string_view(
reinterpret_cast<const char*>(data), size);
(void)parse_u32(text);
return 0;
}
הגבלת האורך מונעת מהבדיקה להשקיע את רוב זמנה במחרוזת עצומה שאין בה עניין נוסף. היא גם אומרת במפורש מה לא נבדק. החיפוש יכול למצוא crash, אך בלי oracle הוא לא יודע אם התוצאה המספרית נכונה. אפשר להשוות מול מימוש בלתי תלוי בעל אותו חוזה, או לבדוק תכונות כמו הוספת אפס בתחילת מספר.
גם דחייה היא התנהגות¶
ספרייה גנרית צריכה לדחות סוגי שימוש שאינם חוקיים. לכן בדיקות compile-fail הן חלק מהבדיקות שלה. קובץ שמשתמש בטיפוס שאינו מקיים concept אמור להיכשל בבנייה, והמערכת צריכה להבחין בין כשל צפוי לבין תקלה אחרת כמו header חסר.
לא כדאי להצמיד בדיקה לטקסט המדויק של הודעת מהדר, מפני שהוא משתנה בין גרסאות. עדיף לבדוק שהקובץ החיובי נבנה, שהקובץ השלילי אינו נבנה, ושהכשל שייך לשורה ולמנגנון הרצויים בסקירה.
בודקים את מסלול הכשל במכוון¶
במכולה מהשיעור הקודם אפשר להיכשל בהקצאה הראשונה, השנייה וכן הלאה. בכל ניסיון בודקים שהמשאבים חזרו למצב תקין ושלא נוצר שינוי חלקי בניגוד לערבות שהבטחנו. אין טעם להמתין עד שהמחשב באמת ייגמר מזיכרון; זה ניסוי לא ממוקד שקשה לשחזר.
הבדיקה דורשת להגדיר מה סופרים. משאב pmr יכול לספור קריאות אליו, אך אינו רואה בהכרח הקצאות שנעשו מחוץ לו. כלי סטטי יכול לזהות מסלולים חשודים, אך אינו מכיר תמיד את חוזה ה־API או את כל האפשרויות בזמן ריצה.
ראיות שמלוות גרסה¶
מטריצת בנייה קטנה יכולה לכלול debug ו־release, שתי ארכיטקטורות ומהדרים נתמכים. תכונות ספרייה שאינן זמינות בכל כלי נבדקות באמצעות feature-test macros אחרי הכללת ה־header המתאים או <version>. הבחירה בחלופה צריכה להיבדק גם היא; קוד שלא נבנה חודשים אינו fallback אמין.
בעת תקלה שומרים קלט, seed, גרסת מהדר, flags וזהות קובץ הבנייה. מתקנים את הסיבה ומוסיפים regression test שמפעיל את התנאי שהכשיל את הגרסה. המטרה היא להיות מסוגלים להסביר למה התיקון פותר את הפגם, ולא רק להציג שורה ירוקה.
לקריאה נוספת: התיעוד של Microsoft על ניתוח קוד C++ והתיעוד הרשמי של LLVM על libFuzzer.