По сравнению со многими современными языками язык Си зачастую кажется крайне примитивным и небезопасным. И одной из частых претензий к языку является невозможность доступа из кода в его же внутреннее представление. В других языках это традиционно осуществляется механизмами, вроде reflections, и довольно удобно в применении.
Тем не менее, с появлением libclang, можно писать собственные анализаторы и генераторы кода прямо в compile time, устраняя достаточно большое множество проблем на ранних этапах работы. Сочетание инструментов статического анализа общего плана (coverity, clang-scan), инструментов анализа для конкретного проекта, а также дисциплины написания кода позволяет намного улучшить качество и безопасность кода, написанного на Си. Конечно, это не даст гарантий, каких дает haskell или даже rust, но позволяет существенно оптимизировать процесс разработки, особенно в случае, когда переписывать огромный проект на другом языке является нереальной задачей.
В данной статье я хотел бы поделиться опытом создания плагина статического анализа format argument для функций, похожих на printf. В ходе написания плагина, мне пришлось очень много рыться в исходниках и doxygen документации libclang, поэтому я счел полезным сделать некоторый обзор для тех, кто хочет ступить на этот тернистый путь, но пока еще не уверен в целесообразности траты времени на сбор информации. В статье не будет картинок, и даже картинок блюющих единорогов, простите.
Постановка задачи
Проблема анализа printf like функций стояла у меня в проекте (https://rspamd.com) довольно давно: стандартный printf из libc не устраивал меня по многим причинам:
- при печати в буфер, printf(3) пытается распарсить всю format string целиком, даже если она включает огромные null-terminated строки, а буфер назначения очень мал:
snprintf(buf, 16, "%s", str)
, гдеstr
— очень длинная строка; такое поведение было мне ни к чему - printf крайне плохо понимает fixed length integers (uint32_t, uitn64_t)
- хотелось печатать собственные структуры данных, например, fixed length strings без ‘\0’ в конце
- хотелось более «продвинутых» флагов форматирования: hex encoding, human readable integers и так далее
- хотелось уметь печатать в собственные структуры данных, например, автоматически расширяемые строки
Поэтому в свое время я взял printf из nginx и адаптировал его для своих задач. Пример кода можно посмотреть тут. У данного подхода есть один недостаток — он совершенно отключает работу стандартного анализатора query string из компилятора, а статические анализаторы общего плана неспособны понять, какие аргументы что значат. Однако эта задача идеально решается при помощи абстрактного синтаксического дерева (AST) компилятора, доступ к которому предоставляется через libclang.
Плагин обработки AST должен выполнять следующие задачи:
- Парсинг query string и извлечение из нее всех ‘%’ аргументов
- Сравнение количества аргументов в query string и переданных функции
- Возможность проверки типа каждого аргумента (включая сложные типы)
- Возможность проверки функций, которые принимают query string в разных позициях (например, printf/fprintf/snprintf)
Компиляция и работа с плагином
Несмотря на то что примеров работы с libclang в интернете достаточно, большинство из них посвящены больше анализу определений, а не анализу выражений, кроме того, почему-то множество примеров написаны на Питоне, писать на котором при наличии прекрасного (на мой взгляд) C++11 мне решительно не хотелось (хотя время компиляции прототипов на C++ — это основной серьезный недостаток).
Первой проблемой, с которой я столкнулся, было то, что разные версии llvm предоставляют разные API. Кроме того, например, osx сборка llvm, установленная через macports, оказалась неработоспособной от слова «никак». Поэтому, я просто установил llvm на свою linux песочницу и работал конкретно с этой версией — 3.7. Впрочем, данный код должен также работать и на 3.6+.
Второй проблемой оказалась система сборки. В моем проекте используется cmake, поэтому я хотел, конечно же, использовать его для построения плагина. Идея была в том, что при включенной опции собирать плагин, а затем уже использовать его для сборки остальной части кода. В первую очередь, как заведено с cmake, пришлось писать пакет для нахождения в системе llvm и libclang, расстановку CXX флагов (например, включение c++11 стандарта). К сожалению, из-за неработоспособности llvm в osx, это напрочь отломало интеграцию с замечательной IDE CLion, которую я использую для повседневной работы, поэтому писать код пришлось без дополнений и прочих удобств, предлагаемых IDE.
Компиляция плагина проблем особых не вызвала:
FIND_PACKAGE(LLVM REQUIRED) SET(CLANGPLUGINSRC plugin.cc printf_check.cc) ADD_LIBRARY(rspamd-clang SHARED ${CLANGPLUGINSRC}) SET_TARGET_PROPERTIES(rspamd-clang PROPERTIES COMPILE_FLAGS "${LLVM_CXX_FLAGS} ${LLVM_CPP_FLAGS} ${LLVM_C_FLAGS}" INCLUDE_DIRECTORIES ${LIBCLANG_INCLUDE_DIR} LINKER_LANGUAGE CXX) TARGET_LINK_LIBRARIES(rspamd-clang ${LIBCLANG_LIBRARIES}) LINK_DIRECTORIES(${LLVM_LIBRARY_DIRS})
А вот с включением его для работы с остальным кодом возникли проблемы. Во-первых, cmake проявлял недюжинный искусственный интеллект, группируя зачем-то опции компилятора, превращая -Xclang opt1 -Xclang opt2
в -Xclang opt1 opt2
, что напрочь ломало компиляцию. Выход нашел через прямую установку CMAKE_C_FLAGS
:
IF (ENABLE_CLANG_PLUGIN MATCHES "ON") SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Xclang -load -Xclang ${CMAKE_CURRENT_BINARY_DIR}/../clang-plugin/librspamd-clang.so -Xclang -add-plugin -Xclang rspamd-ast") ENDIF ()
Как вы видите, пришлось явно указать путь до полученной библиотеки, что потенциально ломало работу системы под osx (где используется .dylib вместо .so), но это было малозначимым фактором из-за неработоспособности llvm под osx. Второй проблемой явилось то, что если указать -Xclang -plugin
, как рекоммендуется почти во всех примерах, то clang перестает компилировать исходники (то есть, он не генерирует объектные файлы), выполняя исключительно анализ. Выходом из ситуации явилась замена -Xclang -plugin
на -Xclang -add-plugin
, что нашлось после некоторой медитации над выдачей гугла.
Написание плагина
В данной части я не хотел бы сильно акцентировать внимание на основах создания плагинов — этому посвящено довольно много материалов. Вкратце, плагин создается при помощи статического метода clang::FrontendPluginRegistry::Add
, который регистрирует плагин для clang. Данный метод является шаблонным, и он принимает тип класса, который наследуется от clang::PluginASTAction
и определяет в нем нужные методы:
class RspamdASTAction : public PluginASTAction { protected: std::unique_ptr <ASTConsumer> CreateASTConsumer (CompilerInstance &CI, llvm::StringRef) override { return llvm::make_unique<RspamdASTConsumer> (CI); } bool ParseArgs (const CompilerInstance &CI, const std::vector <std::string> &args) override { return true; } void PrintHelp (llvm::raw_ostream &ros) { ros << "Nothing here\n"; } }; static FrontendPluginRegistry::Add <rspamd::RspamdASTAction> X ("rspamd-ast", "rspamd ast checker");
Основным интересным методом является метод CreateASTConsumer
, который говорит clang’у, что полученный объект нужно вызвать на этапе, когда компилятор выполнил трансляцию кода в синтаксическое дерево. Вся дальнейшая работа ведется в ASTConsumer, в котором в свою очередь определен метод HandleTranslationUnit
, который, собственно, получает контекст синтаксического дерева. CompilerInstance
используется для управления компилятором, например, для генерации ошибок и предупреждений, что крайне удобно при работе с плагином. Целиком ASTConsumer описан так:
class RspamdASTConsumer : public ASTConsumer { CompilerInstance &Instance; public: RspamdASTConsumer (CompilerInstance &Instance) : Instance (Instance) { } void HandleTranslationUnit (ASTContext &context) override { rspamd::PrintfCheckVisitor v(&context, Instance); v.TraverseDecl (context.getTranslationUnitDecl ()); } };
Здесь мы создаем ASTVisitor, который посещает узлы дерева, и выполняем обход дерева компиляции. В данном классе, собственно, и делается вся работа по анализу вызова функций. Определен этот класс предельно просто (используя pimpl идиому):
class PrintfCheckVisitor : public clang::RecursiveASTVisitor<PrintfCheckVisitor> { class impl; std::unique_ptr<impl> pimpl; public: PrintfCheckVisitor (clang::ASTContext *ctx, clang::CompilerInstance &ci); virtual ~PrintfCheckVisitor (void); bool VisitCallExpr (clang::CallExpr *E); };
Основная мысль — наследование от clang::RecursiveASTVisitor
, выполняющего обход дерева, и определение метода VisitCallExpr
, который вызывается при нахождении в дереве вызова функции. В данном методе (проксированном в pimpl) выполняется основная работа по разбору функций и их аргументов. Начинается метод так:
bool VisitCallExpr (CallExpr *E) { auto callee = dyn_cast<NamedDecl> (E->getCalleeDecl ()); if (callee == NULL) { llvm::errs () << "Bad callee\n"; return false; } auto fname = callee->getNameAsString (); auto pos_it = printf_functions.find (fname); if (pos_it != printf_functions.end ()) {
В данном кусочке кода, мы получаем определение (декларацию) функции из выражения и извлекаем имя функции. Дальше мы ищем в хеше printf_functions
, интересует ли нас данная функция:
printf_functions = { {"rspamd_printf", 0}, {"rspamd_default_log_function", 4}, {"rspamd_snprintf", 2}, {"rspamd_fprintf", 1} };
Число означает позицию query string в аргументах. Далее, если функция нас интересует, мы извлекаем query string и анализируем его (для этого я написал автомат, который несколько за рамками данной статьи):
const auto args = E->getArgs (); auto pos = pos_it->second; auto query = args[pos]; if (!query->isEvaluatable (*pcontext)) { print_warning (std::string ("cannot evaluate query"), E, this->pcontext, this->ci); return false; } clang::Expr::EvalResult r; if (!query->EvaluateAsRValue (r, *pcontext)) { print_warning (std::string ("cannot evaluate rvalue of query"), E, this->pcontext, this->ci); return false; } auto qval = dyn_cast<StringLiteral> ( r.Val.getLValueBase ().get<const Expr *> ()); if (!qval) { print_warning (std::string ("bad or absent query string"), E, this->pcontext, this->ci); return false; }
В этом фрагменте важно то, что мы вначале пытаемся вычислить query string, если это возможно. Это полезно, например, если query string у нас формируется при помощи какого-либо выражения. К сожалению, работа со значениями в libclang делается достаточно трудно: нужно взять выражение, оценить его (EvaluateAsRValue), взять результат, который уже можно преобразовать в LValue, и далее в StringLiteral
. Если вычисление не нужно, то можно брать непосредственно Expr *
и приводить его к StringLiteral
, что сильно упрощает код.
Далее я анализировал query string и получал вектор таких структур:
struct PrintfArgChecker { private: arg_parser_t parser; public: int width; int precision; bool is_unsigned; ASTContext *past; CompilerInstance *pci; PrintfArgChecker (arg_parser_t _p, ASTContext *_ast, CompilerInstance *_ci) : parser (_p), past (_ast), pci(_ci) { width = 0; precision = 0; is_unsigned = false; } virtual ~PrintfArgChecker () { } bool operator() (const Expr *e) { return parser (e, this); } };
Каждая такая структура содержит метод вызова, который принимает аргумент (Expr *
) и проверяет его тип на соответствие заданному. Дальше мы просто проверяем все аргументы после query string на соответствие типам:
if (parsers->size () != E->getNumArgs () - (pos + 1)) { std::ostringstream err_buf; err_buf << "number of arguments for " << fname << " missmatches query string '" << qval->getString ().str () << "', expected " << parsers->size () << " args" << ", got " << (E->getNumArgs () - (pos + 1)) << " args"; print_error (err_buf.str (), E, this->pcontext, this->ci); return false; } else { for (auto i = pos + 1; i < E->getNumArgs (); i++) { auto arg = args[i]; if (arg) { if (!parsers->at (i - (pos + 1)) (arg)) { return false; } } } }
Функция print_error
интересна тем, что она умеет печатать ошибку компиляции и прекращать процесс компиляции. Делается это через CompilerInstance
, но довольно неочевидным способом:
static void print_error (const std::string &err, const Expr *e, const ASTContext *ast, CompilerInstance *ci) { auto loc = e->getExprLoc (); auto &diag = ci->getDiagnostics (); auto id = diag.getCustomDiagID (DiagnosticsEngine::Error, "format query error: %0"); diag.Report (loc, id) << err; }
Соответственно, для вывода предупреждения нужно использовать DiagnosticsEngine::Warning
.
Анализ типов выполняется, в целом, двумя методами. Один умеет проверять встроенные типы, например, long/int итд, а второй — сложные типы, например, структуры. Для проверки простых типов используется clang::BuiltinType::Kind
, который определяет все известные клангу типы. Возможные значения можно поискать в /usr/include/clang/AST/BuiltinTypes.def
(для линукса). Тут есть две тонкости:
- Fixed size int могут по-разному совпадать с built-in type, поэтому надо делать проверки вида
if (sizeof (int32_t) == sizeof (int)) {...} if (sizeof (int32_t) == sizeof (long)) {...}
- Аргументы могут быть алиасами на другие типы, поэтому вначале их надо от этих алиасов избавить, например
typedef my_int int
Итоговая функция проверки простых типов выглядит так:
static bool check_builtin_type (const Expr *arg, struct PrintfArgChecker *ctx, const std::vector <BuiltinType::Kind> &k, const std::string &fmt) { auto type = arg->getType ().split ().Ty; auto desugared_type = type->getUnqualifiedDesugaredType (); if (!desugared_type->isBuiltinType ()) { print_error ( std::string ("not a builtin type for ") + fmt + " arg: " + arg->getType ().getAsString (), arg, ctx->past, ctx->pci); return false; } auto builtin_type = dyn_cast<BuiltinType> (desugared_type); auto kind = builtin_type->getKind (); auto found = false; for (auto kk : k) { if (kind == kk) { found = true; break; } } if (!found) { print_error ( std::string ("bad argument for ") + fmt + " arg: " + arg->getType ().getAsString () + ", resolved as: " + builtin_type->getNameAsCString (ctx->past->getPrintingPolicy ()), arg, ctx->past, ctx->pci); return false; } return true; }
Как видно, для снятия алиасов используется метод getUnqualifiedDesugaredType
, а для получения типа выражения из выражения — arg->getType()
. Но данный метод возвращает qualified type (например, включая спецификатор const
), что для данной задачи не нужно, поэтому qualified type разделяется split
, а из получившейся структуры берется только чистый тип.
Для сложных типов необходимо выделить имя структуры, перечисления или объединения. Функция проверки выглядит так:
static bool check_struct_type (const Expr *arg, struct PrintfArgChecker *ctx, const std::string &sname, const std::string &fmt) { auto type = arg->getType ().split ().Ty; if (!type->isPointerType ()) { print_error ( std::string ("bad string argument for %s: ") + arg->getType ().getAsString (), arg, ctx->past, ctx->pci); return false; } auto ptr_type = type->getPointeeType ().split ().Ty; auto desugared_type = ptr_type->getUnqualifiedDesugaredType (); if (!desugared_type->isRecordType ()) { print_error ( std::string ("not a record type for ") + fmt + " arg: " + arg->getType ().getAsString (), arg, ctx->past, ctx->pci); return false; } auto struct_type = desugared_type->getAsStructureType (); auto struct_decl = struct_type->getDecl (); auto struct_def = struct_decl->getNameAsString (); if (struct_def != sname) { print_error (std::string ("bad argument '") + struct_def + "' for " + fmt + " arg: " + arg->getType ().getAsString (), arg, ctx->past, ctx->pci); return false; } return true; }
Так как мы предполагаем, что аргумент у нас не структура, а указатель на нее, то вначале мы определяем тип указателя через type->getPointeeType().split().Ty
. Затем выполняем desugaring и находим декларацию типа: struct_type->getDecl()
. После чего проверки делаются достаточно тривиальным способом.
Результаты
Разумеется, после написания плагина я начал проверять, как он работает на своем основном коде. Были как простые проблемы с типами:
[ 44%] Building C object src/CMakeFiles/rspamd-server.dir/libutil/map.c.o src/libutil/map.c:906:46: error: format query error: bad argument for %z arg: guint, resolved as: unsigned int msg_info_pool ("read hash of %z elements", g_hash_table_size ^ src/libutil/logger.h:190:9: note: expanded from macro 'msg_info_pool' __VA_ARGS__) ^ 1 error generated.
так и серьезные проблемы:
[ 45%] Building C object src/CMakeFiles/rspamd-server.dir/libserver/protocol.c.o src/libserver/protocol.c:373:45: error: format query error: bad argument 'f_str_tok' for %V arg: rspamd_ftok_t * msg_err_task ("bad from header: '%V'", h->value); ^ src/libutil/logger.h:164:9: note: expanded from macro 'msg_err_task' __VA_ARGS__) ^ 1 error generated. [ 44%] Building C object src/CMakeFiles/rspamd-server.dir/libstat/tokenizers/osb.c.o src/libstat/tokenizers/osb.c:128:48: error: format query error: bad string argument for %s: gsize msg_warn ("siphash key is too short: %s", keylen); ^ src/libutil/logger.h:145:9: note: expanded from macro 'msg_warn' __VA_ARGS__) ^ 1 error generated.
а также проблемы с числом аргументов:
[ 46%] Building C object src/CMakeFiles/rspamd-server.dir/libmime/mime_expressions.c.o src/libmime/mime_expressions.c:780:3: error: format query error: number of arguments for rspamd_default_log_function missmatches query string 'process test regexp %s for url %s returned FALSE', expected 2 args, got 1 args msg_info_task ("process test regexp %s for url %s returned FALSE", ^ src/libutil/logger.h:169:30: note: expanded from macro 'msg_info_task' #define msg_info_task(...) rspamd_default_log_function (G_LOG_LEVEL_INFO, \ ^ 1 error generated.
Всего было найдено 47 проблем с format query, что можно увидеть в следующем коммите: http://git.io/v8Nyv
Код плагина доступен по следующему адресу: https://github.com/vstakhov/rspamd/tree/master/clang-plugin
ссылка на оригинал статьи http://habrahabr.ru/post/270743/
Добавить комментарий