Protocol Buffers — это очень популярный, крутой и качественный проект, развиваемый в основном компанией Google. Это хороший вызов для статического анализатора кода PVS-Studio. Найти хоть что-то — это уже достижение. Попробуем.


PVS-Studio: проверяем Protocol Buffers


Продолжая наш многолетний цикл публикаций про проверку открытых проектов, я обратил внимание на проект Protocol Buffers (protobuf). Библиотека реализует протокол сериализации (передачи) структурированных данных. Это эффективная бинарная альтернатива текстовому формату XML.


Проект показался мне интересным вызовом для анализатора PVS-Studio, ведь компания Google весьма основательно подходит к качеству разрабатываемого C++ кода. Взять хотя бы документ "Безопасное использование C++", который недавно активно обсуждался. Дополнительно protobuf используется большим количеством разработчиков в своих проектах и потому хорошо ими протестирован. Найти хотя бы пару ошибок в этом проекте является достижением, которое будет лестно нашей команде. Так чего мы ждём, вперёд!


Никаких шансов на успех


Раньше мы никогда специально не проверяли этот проект. Однажды, три года назад, мы заглянули в него, когда писали цикл статей про проверку проекта Chromium. Мы заметили интересную ошибку в функции проверки даты, которую описали в отдельной заметке "31 февраля".


Признаюсь, у меня была задумка, когда я писал эту статью. Я хотел показать возможности нового механизма межмодульного анализа С++ проектов. К сожалению, в этот раз межмодульный анализ ничего нового не привнёс. Что с ним, что без него — находятся одни и те же интересные места. Впрочем, это неудивительно. В этом проекте вообще сложно что-то найти :).


Итак давайте посмотрим, какие ляпы скрылись от внимания разработчиков и других инструментов.


Copy-Paste


void SetPrimitiveVariables(....) {
  ....
  if (HasHasbit(descriptor)) {
    (*variables)["get_has_field_bit_message"] = ....;
    (*variables)["set_has_field_bit_message"] = ....;
    (*variables)["clear_has_field_bit_message"] = ....;
    ....
  } else {
    (*variables)["set_has_field_bit_message"] = "";      // <=
    (*variables)["set_has_field_bit_message"] = "";      // <=
    (*variables)["clear_has_field_bit_message"] = "";
  ....
}

Предупреждение PVS-Studio: V519 [CWE-563] The variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 163, 164. java_primitive_field_lite.cc 164


Классическая ошибка, возникшая в процессе копирования строк. Где-то фрагмент строки заменили, где-то нет. В результате ключом два раза является "set_has_field_bit_message".


Если посмотреть код выше, то становится понятно, что на самом деле в else-ветке планировалось написать:


(*variables)["get_has_field_bit_message"] = "";
(*variables)["set_has_field_bit_message"] = "";
(*variables)["clear_has_field_bit_message"] = "";

Утечка файлового дескриптора


ExpandWildcardsResult ExpandWildcards(
    const string& path, std::function<void(const string&)> consume) {
  ....
  HANDLE handle = ::FindFirstFileW(wpath.c_str(), &metadata);
  ....
  do {
    // Ignore ".", "..", and directories.
    if ((metadata.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 &&
        kDot != metadata.cFileName && kDotDot != metadata.cFileName) {
      matched = ExpandWildcardsResult::kSuccess;
      string filename;
      if (!strings::wcs_to_utf8(metadata.cFileName, &filename)) {
        return ExpandWildcardsResult::kErrorOutputPathConversion;       // <=
      }
    ....
  } while (::FindNextFileW(handle, &metadata));
  FindClose(handle);
  return matched;
}

Предупреждение PVS-Studio: V773 [CWE-401] The function was exited without releasing the 'handle' handle. A resource leak is possible. io_win32.cc 400


Перед выходом из функции файловый дескриптор handle должен быть закрыт с помощью вызова FindClose(handle). Однако этого не произойдёт, если не удастся сконвертировать текст из формата UTF-8-encoded в UTF-8. Функция просто завершит работу и вернёт статус ошибки.


Потенциальное переполнение


uint32_t GetFieldOffset(const FieldDescriptor* field) const {
  if (InRealOneof(field)) {
    size_t offset =
        static_cast<size_t>(field->containing_type()->field_count() +
                            field->containing_oneof()->index());
    return OffsetValue(offsets_[offset], field->type());
  } else {
    return GetFieldOffsetNonOneof(field);
  }
}

Предупреждение PVS-Studio: V1028 [CWE-190] Possible overflow. Consider casting operands, not the result. generated_message_reflection.h 140


Складываются два значение типа int и помещаются в переменную типа size_t:


size_t offset = static_cast<size_t>(int_var_1 + int_var_2);

Предполагается, что в случае 64-битной сборки сумма значений двух 32-битных переменных может превысить значение INT_MAX. Поэтому результат помещается в переменную типа size_t, которая будет 64-битной в 64-битной программе. Более того, понимая, что при сложении двух int может произойти переполнение, программистом используется явное приведение типа.


Вот только используется это явное приведение типа неправильно. Оно ни от чего не защищает. И без него сработало бы неявное расширение типа от int к size_t. Так что написанный код ничем не отличается от варианта:


size_t offset = int_var_1 + int_var_2;

Скорее всего, по невнимательности скобку поставили не там, где нужно. Правильным вариантом является:


size_t offset = static_cast<size_t>(int_var_1) + int_var_2;

Разыменование нулевого указателя


bool KotlinGenerator::Generate(....)
{
  ....
  std::unique_ptr<FileGenerator> file_generator;
  if (file_options.generate_immutable_code) {
    file_generator.reset(
        new FileGenerator(file, file_options, /* immutable_api = */ true));
  }

  if (!file_generator->Validate(error)) {
    return false;
  }
  ....
}

Предупреждение PVS-Studio: V614 [CWE-457] Potentially null smart pointer 'file_generator' used. java_kotlin_generator.cc 100


Если переменная generate_immutable_code вдруг окажется равна fasle, то умный указатель file_generator останется равен nullptr. Как следствие, произойдёт разыменование нулевого указателя.


Видимо, пока переменная generate_immutable_code всегда истинна, раз эта ошибка ещё не обнаружена. Её можно счесть несущественной. Как только в процессе редактирования кода логика работы изменится, то произойдёт разыменование нулевого указателя, что будет сразу замечено и исправлено. А с другой стороны, в этом коде находится мина, которую лучше найти и исправить заранее, чем ждать, когда кто-то на ней подорвётся в будущем. Суть статического анализа как раз том, чтобы найти ошибки заранее.


Там ли скобка?


AlphaNum::AlphaNum(strings::Hex hex) {
  char *const end = &digits[kFastToBufferSize];
  char *writer = end;
  uint64 value = hex.value;
  uint64 width = hex.spec;
  // We accomplish minimum width by OR'ing in 0x10000 to the user's value,
  // where 0x10000 is the smallest hex number that is as wide as the user
  // asked for.
  uint64 mask = ((static_cast<uint64>(1) << (width - 1) * 4)) | value;
  ....
}

Нас интересует это подвыражение:


((static_cast<uint64>(1) << (width - 1) * 4))

Код не нравится анализатору сразу по 2 причинам:


  • V634 [CWE-783] The priority of the '*' operation is higher than that of the '<<' operation. It's possible that parentheses should be used in the expression. strutil.cc 1408
  • V592 The expression was enclosed by parentheses twice: ((expression)). One pair of parentheses is unnecessary or misprint is present. strutil.cc 1408

Согласитесь, предупреждения дополняют друг друга. Есть совместное использование оператора сдвига и умножения. Легко забыть, какой из этих операторов более приоритетен. А повторяющиеся скобочки намекают на то, что про эту неоднозначность знали и хотели её избежать. Но не получилось.


Есть два варианта. Первый: код корректен. В этом случае дополнительные скобки просто должны упрощать чтение кода, но ни на что не влияют:


uint64 mask = (static_cast<uint64>(1) << ((width - 1) * 4)) | value;

Второй: выражение написано с ошибкой. Тогда дополнительные скобки должны поменять последовательность выполняемых операций:


uint64 mask = ((static_cast<uint64>(1) << (width - 1)) * 4) | value;

Заключение


Приятно, что удалось найти недоработки в таком известном и качественном проекте. С другой стороны, это плохой проект для демонстрации возможностей статических анализаторов кода :). Сложно показать пользу от инструмента, если находится всего пара ошибок :).


PVS-Studio хранит сон программиста


Следует помнить, что пользу статический анализатор приносит при регулярном использовании для проверки свежего кода, а не при разовых проверках уже оттестированных проектов.


Тем не менее, нужно с чего-то начать. Поэтому предлагаю скачать PVS-Studio, проверить проект и взглянуть на самые лучшие предупреждения. Скорее всего, вы увидите много для себя интересного :).


Если же ваш код столь же качественный, как protobuf, то предлагаем сразу перейти к полноценному сценарию использования инструмента. Попробуйте внедрить PVS-Studio в процесс разработки и посмотреть, что интересного будет находиться каждый день. Как это сделать в случае, если у вас большой проект, описано здесь.


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Karpov. Protocol Buffers, a brutal protocol from Google, vs. PVS-Studio, a static code analyzer.

Комментарии (23)


  1. datacompboy
    05.11.2021 15:36

    Эмм.... А откуда исходники, версия? Я тут смотрю в поисках вот этого куска кода:

        (*variables)["set_has_field_bit_message"] = "";      // <=
        (*variables)["set_has_field_bit_message"] = "";      // <=

    И не вижу его ни в одной версии исходников O_O


    1. datacompboy
      05.11.2021 15:39
      -2

      Ох. Мои глаза :D не в тот файл смотрю =) Просто строка-дубликат, не ошибка, пьфью.


      1. marshinov
        05.11.2021 17:39
        +6

        Кажется там должно быть (*variables)["get_has_field_bit_message"]


        1. datacompboy
          05.11.2021 18:04

          Нет, там не предполагалось второй. Код вообще получен не копипастой, а редактированием - в первом случае дубликат убрали, во втором проглядели.


  1. qw1
    06.11.2021 13:47
    +1

    Последняя не ошибка. То, что анализатор будет ругаться на любое выражение типа
    1 << 4*x
    это странно.


    1. datacompboy
      06.11.2021 14:45

      Вне зависимости от того ошибка это или нет, это код который нетривиально парзить человеку.


      1. qw1
        06.11.2021 15:20
        +1

        Иногда это позволит найти какую-то ошибку.
        С другой стороны, разбирать 100500 false positive тоже сомнительное удовольствие.


        1. Andrey2008 Автор
          06.11.2021 15:44

          Не будет из 100500. Не верится, что такие выражения часто требуются. А если это особый проект, где их много, то можно отключить диагностику.


    1. Andrey2008 Автор
      06.11.2021 15:43

      И правильно что ругается, ибо каждый раз нужно вспоминать (или подглядывать) приоритет операций. Если лишнее — эту диагностику можно отключить. В данном случае ситуация ещё усугубляется повторяющимися скобками.


      1. qw1
        06.11.2021 19:37
        +1

        А на это будет ругаться:
        if (a && b || c && d) { ... }

        А на это:
        x = 1+3*y;

        А почему такая разница?


        1. Andrey2008 Автор
          06.11.2021 20:49

          if (a && b || c && d) // нет
          if (a || b && c || d) // да
          x = 1 + 3 * y; // нет
          

          1. Первая строка. Тот, кто писал, скорее всего понимает, что хочет объединить операцией || два подвыражения. Типовой код. Ошибки скорее всего нет.
          2. Вторая строка. Возможно, код работает не так, как ожидает программист. Возможно, ошибки нет, но код лучше перепроверить.
          3. Третья строка. Невероятно, что кто-то спутает приоритет операций + и *. Ошибки скорее всего нет.


          1. qw1
            07.11.2021 01:02

            А если
            if (a || b && c)
            ?

            Я просто правило хочу понять )))


            1. Andrey2008 Автор
              07.11.2021 11:38

              Просто не получится :). Это ведь ещё и разные диагностики :). Изначально речь шла про V634. Она именно оценивает выражения со сдвигами.

              Паттерн вида x << y + z.
              Забывают, что у сложения/вычитания/умножения/деления более высокий приоритет, чем у сдвига. Лучше всегда явно ставить скобки.
              Пример:

              int x = a<<4 + b;

              Исключения:
              1. Операция с высоким приоритетом находится в макросе, а сдвиг нет.
              2. Операция с высоким приоритетом ('-','/','%') выполняется над числом 64(int64), 32(int), 16(short), 8(char).
              3. Разделение на строки выражения свидетельствует о преднамеренном использовании:
                 a = 1 <<
                      2 * 8; //ok
                 b = 2 
                      << 2 * 8 //ok
                 c = 3 << 2
                          * 8 //err
                
              4. Нет смысл сдвигать вправо константу на константу или влево константу (кроме 1) на константу.
              5. Сдвиг является условием. В этом случае нет смысла результат сдвига умножать. Пример: if (value >> (i — 1) * 8)


              А если речь заходит про && и ||, то это уже V648.

              Странно, когда логические операции '&&' и '||' используются вместе и не разделены скобками. Часто их приоритеты путают. Приоритет оператора && выше, чем у ||.

              Следует предупреждать в том случае, если написано так: A || B && C.
              Исключение:
              1. Если имеется выражение вида A || !A && B, т.е. оператор || разделяет два противоположных по смыслу высказывания.
              2. Если выражение вида… || p != nullptr && f(p) ...
              3. Имеется выражение вида x == 0 || A != 123 && A != 321, т.е. оператор &&
                разделяет сравнение одной и той же переменной с разными константами.
                При этом, с одной стороны от последовательности || && ничего не должно быть.
                   Т.е. здесь не ругаемся: b || A != 0 && A != 1
                         а здесь ругаемся: b || A != 0 && A != 1 || c
                         и здесь ругаемся: b || A != 0 && (A != 1 || c)
                


              1. qw1
                07.11.2021 16:22
                +2

                Понятно. Всякие очевидные false positive прописываются в исключения, поэтому в результате их будет немного. Плюс прогоны инструмента на гигабайтах реальных исходников, и что там будет часто встречаться (при этом не являясь ошибкой), тоже будет загнано в исключения.


                1. Andrey2008 Автор
                  07.11.2021 16:40

                  Да :)


  1. datacompboy
    06.11.2021 15:40
    -1

    size_t offset = static_cast<size_t>(int_var_1 + int_var_2);

    Мну тут таки не согласно. Проблема тут не в int32/unit64, а скорее в signed / unsigned операциях. В зависимости от компилятора int может быть и 32 и 64, но size_t точно не меньше их размеров и точно unsigned. Так как int_var_1 и int_var_2 тут это количество и смещение (всегда неотрицательные), их сумма точно всегда влезет в size_t -- но не факт что всегда влезет в int.


    1. Andrey2008 Автор
      06.11.2021 15:46

      Анализатор отталкивается не от теоретических, а практических типов :). В данном режиме сборки int 32-битный. А значит может быть переполнение.


      1. datacompboy
        06.11.2021 16:11
        -1

        Я к тому что неважно 32 или 64 -- переполнение возможно всё равно так как int+int не обязательно влезает в int. (К слову у меня сборка на линупсе так что всё 64бита -- и всё равно возможно! впрочем тогда надо СЛИШКОМ большие структуры... :D)


    1. demp
      07.11.2021 14:46
      +3

      Похоже здесь static_cast<size_t> был добавлен просто для того, чтобы задушить предупреждение компилятора о преобразовании знакового int в беззнаковый size_t.

      Любовь Гугла к int для индексов и количества элементов в контейнере все время наталкивается на реальность стандартной библиотеки, где для этого используется size_t.

      Но больше всего меня, как пользователя protobuf, раздражает их использование int для размера буфера. Ведь в Гугле уверены, что 640 кБ 2 ГБ хватит всем. Поэтому я останавливаюсь, вздыхаю и мысленно ругаюсь, когда приходится писать или читать кода вида

      const std::string buf = protobufMessage.SerialzeAsString(); // ага, авторы protobuf решили, что std::string это самый подходящий тип для буфера с бинарными данными

      protobufMessage.ParseFromArray(buf.data(), static_cast<int>(buf.size())); // избавляемся от предупреждения и верим что тут всегда будет меньше 2 ГБ


      1. qw1
        07.11.2021 16:23

        Жаль, что после релиза уже нельзя исправить сигнатуры — у всех пользователей библиотеки, которые глушили предупреждения cast-ами, снова полезут warning-и.


        1. demp
          08.11.2021 11:35
          +3

          Со сменой типа параметров это может быть чуть проще. При желании можно добавить перегруженную версию ParseFromArray(const void* data, size_t size), опционально пометить ParseFromArray(const void* data, int size) как deprecated.

          При смене возвращаемого типа тоже все решаемо, например какByteSize() -> ByteSizeLong().

          Проблема в точке зрения авторов protobuf, что int для размеров это нормально.


          1. qw1
            08.11.2021 11:52

            Да, не подумал. В принципе, int-размеров достаточно для всех моих применений, но неидеоматичность современному C++ и постоянные касты неприятны.


  1. datacompboy
    06.11.2021 16:16
    +3

    Спасибо! Подготовил исправления по всем пунктам :)