PHP широко известен как интерпретируемый язык программирования, использующийся в основном для разработки сайтов. Однако немногие знают, что для PHP есть ещё и компилятор под .NET — PeachPie. Но вот насколько он качественно сделан? Сможет ли статический анализатор найти в этом компиляторе реальные ошибки? Давайте же узнаем! Давненько не выходили статьи о проверках C#-проектов с помощью PVS-Studio… А ведь нам ещё составлять топ ошибок за 2021 год (топ за 2020 год, кстати, можно глянуть тут)! Что ж, нужно срочно исправляться. Я рад представить вам обзор результатов проверки PeachPie.  Для начала немного расскажу о проверяемом проекте. PeachPie — это современный компилятор языка PHP с открытым исходным кодом и среда выполнения для .NET Framework и .NET. Он построен на платформе компилятора Microsoft Roslyn и основан на проекте Phalanger первого поколения. С июля 2017 года данный проект также входит в .NET Foundation. Исходный код доступен в репозитории на GitHub. Кстати, наш C#-анализатор тоже широко использует возможности Roslyn, так что можно сказать, что у PeachPie и PVS-Studio есть что-то общее :). Вообще основам работы с Roslyn посвящена целая статья, написанная как раз на основе нашего опыта работы с этой платформой.  Для проведения же проверки PeachPie нужно было установить анализатор, открыть проект в Visual Studio или Rider и запустить анализ, используя плагин PVS-Studio. Подробнее об этом написано в документации.  Проверять такой большой и серьёзный проект было очень интересно. Надеюсь, что мой обзор найденных в PeachPie ошибок вам покажется не менее интересным. Приятного чтения! Начнём, пожалуй, с простого :) Порой ошибки могут возникать в самых неожиданных и при этом самых простых местах. Например, ошибка может оказаться даже в банальном вызове функции WriteLine: Предупреждение V3025: Incorrect format. A different number of format items is expected while calling 'WriteLine' function. Arguments not used: 1st, 2nd, 3rd, 4th, 5th. Mail.cs 25 Казалось бы, а что же тут не так? Вроде бы всё выглядит нормально. Хотя, постойте-ка! А каким по порядку аргументом нужно передавать формат? Что ж, давайте взглянем на объявление Debug.WriteLine: Получается, что строка формата должна передаваться первым аргументом, а в коде первым аргументом является "MAILER". Очевидно, разработчик перепутал методы и передал аргументы некорректным образом. Очередной раздел посвящается срабатываниям, связанным с выполнением одинаковых действий в разных case-ветках: В данном фрагменте закралась если не ошибка, то как минимум странность. Как быстро вы сможете её найти? Впрочем, ни к чему терять время, анализатор всё нашёл за нас: Предупреждение V3139: Two or more case-branches perform the same actions. ReflectionUtils.Nullability.cs 170 Не правда ли, странно, что два разных случая обрабатываются одинаково? На самом деле нет, в принципе такое бывает достаточно часто. Однако есть 2 "но". В первую очередь стоит отметить, что существует более элегантный способ одинаково обработать два различных случая. Рассмотренный фрагмент можно было бы записать как-то так: Тем не менее, разработчики нередко пренебрегают этим удобным способом, предпочитая копипаст. Из-за этого наличие двух одинаковых веток само по себе не кажется таким уж страшным. Куда более подозрительным выглядит тот факт, что перечисление FlowAnalysisAnnotations имеет среди прочих значение FlowAnalysisAnnotations.NotNull. Складывается впечатление, что именно оно должно было использоваться при обработке значения "System.Diagnostics.CodeAnalysis.NotNullAttribute": Разработчики частенько допускают ошибки, связанные с неверным пониманием особенностей работы "изменяющих" методов. Ниже же представлена ошибка, найденная в PeachPie: Предупреждения PVS-Studio: Анализатор сообщает, что результаты вызовов должны куда-нибудь записываться – в противном случае они попросту не имеют никакого смысла. Дело в том, что методы вроде AddHours не меняют исходный объект – вместо этого возвращается новый, отличающийся от исходного соответствующим образом. Сложно сказать, насколько критична эта оплошность, но очевидно, что код работает некорректно. Try-методы очень часто бывают удобны при разработке приложений на C#. Наиболее известными их представителями являются int.TryParse, Dictionary.TryGetValue и т.д. Привычно, что эти методы возвращают флаг, указывающий на успешность выполнения операции, а сам результат записывается в out-параметр. Разработчики PeachPie решили реализовать свои try-методы, которые, казалось бы, должны были работать по той же схеме. Что же из этого вышло? Давайте взглянем на следующий код: Данный метод сильно сокращён для удобства восприятия. При желании вы можете посмотреть его полностью, перейдя по ссылке. В методе множество раз производится вызов Core.Convert.TryParseDigits. В случаях когда такой вызов возвращает false, поток выполнения переходит к метке Error, что вполне логично. На метке Error out-параметрам присваиваются значения по умолчанию, после чего метод возвращает false. Всё выглядит вполне логично – метод TryParseIso8601Duration ведёт себя именно так, как и стандартные try-методы. Ну… По крайней мере, это так выглядит. Фактически же всё совсем не так :(. Как уже было сказано ранее, если Core.Convert.TryParseDigits возвращает false, то код переходит на метку Error, где и производится обработка ошибки/неудачи. Правда, вот в чём беда – анализатор-то сообщает, что TryParseDigits никогда не возвращает false: Предупреждение V3022: Expression '!Core.Convert.TryParseDigits(s, ref pos, false, out value, out numDigits)' is always false. DateTimeParsing.cs 1440 Если отрицание результата вызова всегда равно false, то сам вызов всегда возвращает true. Достаточно специфичное поведение для try-метода! Неужели операция и правда всегда завершается успешно? Давайте, наконец, взглянем на TryParseDigits: Метод действительно всегда возвращает true. Но операция может и завершиться неудачей – в этом случае будет выброшено исключение типа OverflowException. Как по мне, это явно не то поведение, которого ожидаешь от try-метода :). Строка с return false, кстати, тут есть, но она закомментирована. Возможно, использование в этом месте исключения как-то обосновано. Но по коду складывается впечатление, что тут что-то пошло не по плану. И TryParseDigits, и использующий его TryParseIso8601Duration, по идее, должны работать как привычные try-методы – возвращать в случае неудачи false. Вместо этого они выбрасывают неожиданные исключения. Следующее срабатывание анализатора попроще, однако и оно указывает на весьма странный фрагмент кода: Предупреждение V3061: Parameter 'mode' is always rewritten in method body before being used. Ftp.cs 306 Метод ftp_put принимает на вход ряд параметров, одним из которых является mode. Данный параметр имеет значение по умолчанию, однако при вызове, очевидно, можно задать и другое значение. Правда, это ни на что не повлияет – mode всегда перезаписывается, и в метод Put всегда передаётся значение константы FTP_IMAGE. Сложно сказать, почему всё написано именно так – конструкция кажется бессмысленной. Скорее всего, здесь всё же допущена ошибка. Следующий фрагмент кода похож на жертву копирования: Предупреждение V3127: Two similar code fragments were found. Perhaps, this is a typo and 'NO_RES_RANGE' variable should be used instead of 'NO_PRIV_RANGE' Filter.cs 771 Складывается впечатление, что второе условие нужно было записать так: (flags & (int)FilterFlag.NO_RES_RANGE) == (int)FilterFlag.NO_RES_RANGE Во всяком случае, такой вариант выглядит логичнее и не вызывает вопросов. Думаю, можно разбавить наш сегодняшний разбор и обычным избыточным кодом: Предупреждение V3063: A part of conditional expression is always true if it is evaluated: num <= long.MaxValue % 16. Conversions.cs 994 В первую очередь хочется сказать, что код функции очень сильно урезан для удобства восприятия. Полностью исходный код IsNumber доступен по ссылке – но хочу предупредить, что изучать её будет непросто, ибо функция содержит более 300 строк кода. Кажется, что она слегка выходит за принятые рамки "одного экрана" :). Перейдём к срабатыванию. Во внешнем блоке производится проверка, что значение переменной num меньше или равно 15. Во внутреннем же есть проверка, что num меньше или равно long.MaxValue % 16. При этом значение этого выражения равно 15 – это нетрудно проверить. Выходит, код дважды проверяет, что num меньше или равен 15. Едва ли это срабатывание указывает на реальную ошибку – просто кто-то написал лишнюю проверку. Возможно, на то даже были причины – например, разработчику было проще читать именно такой код. Хотя кажется, что использование какой-нибудь переменной или константы для хранения результата сравнения было бы более простым вариантом. Как бы там ни было, конструкция избыточна, и долг статического анализатора – об этом сообщить. Довольно часто разработчики упускают проверки на null. Особенно интересна ситуация, когда в одном месте функции переменную проверили, а в другом (где она всё так же может быть null) – забыли или не посчитали нужным. И тут остаётся лишь гадать, была ли проверка лишней или напротив – кое-где её не хватает. Проверки на null не всегда предполагают использование операторов сравнения – например, в коде ниже разработчик использовал null-conditional оператор: Предупреждение V3105: The 'tinfo' variable was used after it was assigned through null-conditional operator. NullReferenceException is possible. Objects.cs 189 По мнению разработчика, вызов Type.GetTypeFromHandle(caller) может вернуть null – оттого он и использовал "?." для вызова GetPhpTypeInfo. Судя по документации, это действительно возможно.  Ура, "?." спасает от одного исключения. Если вызов GetTypeFromHandle действительно вернёт null, то в переменную tinfo также будет записан null. Однако при попытке обращения к свойству BaseType будет выброшено другое исключение. Скорее всего, в последней строке не хватает ещё одного "?": return tinfo?.BaseType?.Name; Приготовьтесь, в этом разделе вас ждёт настоящее расследование... Ещё одно срабатывание, связанное с проверкой на null, оказалось куда интереснее, чем выглядело на первый взгляд. Взгляните на код: Предупреждение V3125: The 'context' object was used after it was verified against null. Check lines: 3138, 3133. Hash.cs 3138 Действительно, переменная проверяется на равенство null, а затем без какой-либо проверки производится обращение к свойству. Однако посмотрите, что произойдёт, если переменная будет равна null: Получается, что если context и правда будет равен null, то до обращения к свойству HashAlgorithm поток выполнения не доберётся. Следовательно, данный код безопасен. Получается, ложное срабатывание? Конечно, анализатор может и ошибаться. Однако мне было известно, что PVS-Studio подобные ситуации обрабатывать умеет – анализатор должен был знать, что в момент обращения к HashAlgorithm переменная context не может быть равна null. А всё-таки что именно делает вызов PhpException.ArgumentNull? Давайте взглянем: Хм, действительно, вроде что-то выбрасывается. Обратите внимание на первый аргумент вызова — PhpError.Warning. Хм, ну что ж, перейдём к самому методу Throw: Тут в принципе ничего интересного, перейдём в другую перегрузку Throw: А вот и то, что мы искали! Получается, что под капотом всей этой системы производится выбрасывание PhpFatalErrorException. Вот только похоже, что выбрасывается исключение не всегда.  В первую очередь стоит поглядеть на места, где регистрируются обработчики события OnError. Всё же они тоже могут кидать исключения – это было бы слегка неожиданно, но мало ли. Таких оказалось немного и все они связаны с логированием соответствующих сообщений. Один был в файле PhpHandlerMiddleware: Другие 2 определялись внутри самого класса PhpException: Таким образом, никаких исключений обработчики события не генерируют. Поэтому вернёмся к методу Throw. Раз с OnError всё ясно, то давайте подробнее разберём это условие: Параметр error хранит значение перечисления PhpError. Чуть ранее мы видели, что в этот параметр передаётся PhpError.Warning. Исключение же будет выброшено в случае, если результат применения "побитового и" к error и PhpErrorSets.Fatal не будет нулевым. Значение PhpErrorSets.Fatal представляет собой "объединение" набора элементов перечисления PhpError с помощью операции "побитового или": Ниже представлены значения всех рассмотренных ранее элементов перечисления: Получается, операция error & (PhpError)PhpErrorSets.Fatal вернёт ненулевое значение только в случае, если параметр error будет иметь одно из следующих значений или их сочетаний: Если в параметр error записано значение PhpError.Warning, равное PhpError.E_WARNING, то результат операции "побитового и" будет нулевым. Тогда и условие выбрасывания исключения PhpFatalErrorException не будет выполнено. Вернёмся назад к методу PhpException.ArgumentNull: Мы разобрались, что при передаче PhpError.Warning исключение выброшено не будет. Возможно, по задумке исключение действительно не должно выбрасываться в случаях, когда передан неожиданный null. Вот только... Если PhpException.ArgumentNull не выбрасывает исключение (что само по себе уже очень неожиданно), то при обращении к свойству HashAlgorithm всё равно будет выбрасываться NullReferenceException! Возникает вопрос – должно ли всё-таки выбрасываться исключение или нет? Если должно, то логичнее было бы использовать тот же PhpFatalErrorException. Если же исключения в этой ситуации никто не ожидает, то нужно корректно обработать значение null параметра context. К примеру, использовать "?.". Так или иначе, анализатор всё-таки отработал корректно и даже помог разобраться в этой странной ситуации. Прошлый случай показывает, как можно ожидать исключение, а нарваться на неожиданный null. Фрагмент ниже – противоположность того случая: Предупреждение V3022: Expression 'node != null' is always true. Datastructures.cs 432 Ну, как говорится, не null и ладно – чего бухтеть-то? Вот только обычно null ожидается в тех случаях, когда что-то пошло не так. По коду видно, что здесь именно такая ситуация. Анализатор же утверждает, что никакого null тут быть не может. Можно подумать, что здесь дело в вызове Debug.Assert. Хорошо это или плохо, но на самом деле этот вызов не влияет на мнение анализатора. Если дело не в Debug.Assert, то в чём же? С чего анализатор считает, что node никогда не равно null? Давайте взглянем на метод GetNodeAtIndex, который и возвращает значение, записанное в node: Что же, копаем дальше. Поглядим на вызванный здесь GetNodeAtIndex: Глядите – похоже, метод действительно мог бы вернуть null… Но не тут-то было – если после завершения работы цикла node будет равно null, то производится выбрасывание исключения. Таким образом, никакого null возвращено быть не может. Получается, что в случае возникновения непредвиденной ситуации метод GetNodeAtIndex не вернёт null, как ожидалось в коде метода offsetGet: При просмотре этого метода любой разработчик может легко обмануться. Ведь по коду складывается впечатление, что либо будет возвращено корректное значение, либо PhpValue.Null. Фактически же этот метод может выбросить исключение. Такая вот беда — неожиданное поведение лишь одного метода в цепочке вызовов приводит к неожиданному поведению всех этих методов. Данный пример хорошо иллюстрирует полезность статического анализа, позволяющего находить такие проблемы автоматически. Аналогичная проблема присутствует, кстати, и в методе offsetSet из того же класса: Предупреждение V3022: Expression 'node != null' is always true. Datastructures.cs 444 Предлагаю немного отдохнуть от всех этих расследований и выпить кружечку кофе.  Пока пьём кофе, рассмотрим более простое срабатывание, которое тем не менее указывает на очень странный фрагмент кода: Предупреждения PVS-Studio: Выглядит так, будто разработчик запутался во всех этих присваиваниях и где-то опечатался. Это привело к тому, что значения присваиваются полям st_ctime и st_atime по 2 раза – и ведь второй раз присваивается не то же самое значение, что в первый. Вроде бы ошибка, верно? Но так ведь совсем не интересно! Предлагаю вам потренировать свои навыки поиска глубинного смысла и в комментариях предложить какое-нибудь объяснение того, зачем всё написано именно таким образом. Ну а пока идём дальше :) Давным-давно, когда вы ещё только просматривали первые срабатывания из этой статьи, мы упоминали неизменяемость экземпляров структуры DateTime. Следующие же предупреждения напомнят нам об аналогичном свойстве строк: Предупреждения PVS-Studio: В принципе, всё просто и понятно – хотели внести в строку изменения, но как-то… не срослось :(. Недавно мы разбирали случай, когда разработчик ожидал от функции null, а получил исключение. Ниже представлено нечто похожее, но попроще: Предупреждение V3022: Expression 'wrapperClass == null' is always false. Streams.cs 555 Конечно, можно провести подробный разбор, но… Тут и по названию метода ведь всё видно! GetDeclaredTypeOrThrow как бы намекает, что если что не так, то он будет стрелять. И ведь смотрите опять какая штука – это поведение будет передаваться и методу stream_wrapper_register, который по задумке должен был бы просто вернуть false. Ага, конечно, ловите исключение! Вообще, мы уже сталкивались ранее с обманывающими названиями. Помните, как вызов метода PhpException.ArgumentNull на самом деле не бросал исключение? Поэтому давайте всё-таки проверим, действительно ли GetDeclaredTypeOrThrow бросает исключение: Ну, здесь не обманули – и правда исключение :). Бывают случаи, когда разработчики используют в качестве условия продолжения цикла while значение true. Кажется, что это в целом нормальная практика – для выхода из цикла может использоваться break, return, ну или те же исключения. Куда более странно выглядит цикл, в качестве условия которого используется не ключевое слово true, а некоторое выражение, значение которого всегда имеет значение true: Предупреждение V3022: Expression 'haveskipped != offset' is always true. Streams.cs 769 Переменная haveskipped объявлена перед запуском цикла и инициализирована значением 0. Это значение останется с ней… до самой смерти. Мрачновато вышло, но как есть. По сути, haveskipped — это константа. Значение параметра offset во время выполнения цикла также не меняется. Да и вообще не меняется ни в одном месте функции, на самом деле (можете проверить тут). Задумывал ли разработчик, что условие продолжения цикла будет всегда истинным? В теории это возможно, конечно. Но взгляните на цикл повнимательнее. Следующее присваивание в нём выглядит странно: Какой в этом смысл, если haveskipped всегда равен 0? С этим циклом явно что-то не то. То ли тут действительно допущена серьёзная ошибка, то ли все эти странности с haveskipped – останки каких-то старых нереализованных идей. Довольно часто ошибки связаны с использованием некорректных операторов в условиях. Нашлось подобное и в компиляторе PHP: Предупреждение V3080: Possible null dereference. Consider inspecting 'data'. PhpStream.cs 1382 В цикле производится проверка значения переменной data. Если она равна null и при этом её свойство Length имеет положительное значение, то производится выход из цикла. Очевидно, это невозможно. Более того, обращение к свойству Length переменной, имеющей значение null, приведёт к выбрасыванию исключения. Здесь же обращение подчёркнуто производится именно тогда, когда data = null. Учитывая комментарий разработчика, я бы переписал условие как-то так: Тем не менее, не факт, что это действительно корректный вариант обработки – для внесения исправления стоит провести более глубокое исследование кода. Бывают и ошибки, которые в принципе не выглядят особенно страшными, но всё же могут доставить проблем. Например, в следующем фрагменте copy-paste нанёс ещё один удар: Предупреждение V3013: It is odd that the body of 'addGlob' function is fully equivalent to the body of 'addPattern' function (506, line 515). ZipArchive.cs 506 Функция addGlob, очевидно, не поддерживается, поэтому при её вызове выбрасывается исключение, сообщающее о том, что addGlob не поддерживается. Поверили? А зря! Никакого исключения тут не будет – это ж наш старый знакомый PhpException: Как мы разбирали ранее, если в метод Throw передаётся значение PhpError.Warning, то исключения не будет. Но всё же возникшая ошибка наверняка будет записана в какой-нибудь лог или обработана как-то ещё. Вернёмся к исходному фрагменту кода: addGlob не поддерживается и при вызове соответствующее сообщение будет как-то обработано – будем считать, что оно запишется в лог. addPattern тоже не поддерживается, правда, вот соответствующее сообщение будет всё равно посвящено addGlob. Очевидно, проблема возникла из-за копирования. Исправить легко – просто в методе addPattern нужно сообщать про addPattern, а не про addGlob: Иногда разработчики забывают об особенностях работы некоторых функций, из-за чего проверяют совсем не те значения, которые нужно. Из-за этого проверка оказывается бессмысленной, а в правильном месте её и вовсе нет. Похоже, примерно это и случилось с функцией getallheaders: Предупреждение V3022: Expression 'string.Join(", ", h.Value)' is always not null. The operator '??' is excessive. Web.cs 932 Использование оператора "??" не имеет здесь никакого смысла, так как метод string.Join никогда не вернёт null. А вот бросить ArgumentNullException – это всегда пожалуйста! string.Join выбрасывает исключение в том случае, если переданная ссылка на последовательность равна null. Поэтому безопаснее будет записать эту строку как-то так: Вообще, я хотел узнать, может ли это Value в принципе быть null. А то, может, тут и вовсе не нужны никакие проверки. Для этого нужно было понять, откуда пришла коллекция headers. Значение headers взято из webctx.RequestHeaders, а значение webctx, в свою очередь, берётся из свойства HttpPhpContext объекта ctx. Ну а свойство HttpPhpContext… Что ж, глядите сами: Это, видимо, некий "на потом". Если взглянете на метод getallheaders ещё разок, то увидите, что он, получается, вообще никогда не отрабатывает и просто возвращает null. Что, опять поверили? Ну что же с вами делать! Свойство-то виртуальное. Следовательно, чтобы понять, что же фактически может быть им возвращено, надо изучать наследников. Лично я решил на этом остановиться – всё же мне нужно показывать и другие срабатывания. Чем больше и сложнее метод, тем больше вероятность наличия в нём ошибки. Разработчикам со временем становится сложно ориентироваться в большой куче кода, при этом всегда очень страшно что-то в этом коде менять. Новый код добавляется, старый остаётся прежним, кое-как эта невероятная конструкция вроде работает, ну и слава богу. Нет ничего удивительного в том, что в таком коде оказываются различные странности. К примеру, давайте взглянем на метод inflate_fast: Предупреждение V3008: The 'r' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 621, 619. InfCodes.cs 621 Для начала вот ссылка на полный код. Метод состоит более чем из двух сотен строк кода с кучей вложенных конструкций. Кажется, что разобраться в нём явно было бы непросто. Срабатывание довольно однозначное – сначала в блоке переменной r присваивается новое значение, а затем оно безусловно перезаписывается нулём. Сложно сказать, что именно здесь не так. То ли обнуление работает как-то не так, то ли конструкция r += e здесь лишняя. Ранее мы уже рассмотрели случай, когда некорректно построенное логическое выражение приводит к выбрасыванию исключения. Ниже приведён ещё один пример подобного срабатывания: Предупреждение V3080: Possible null dereference. Consider inspecting 'langVersion'. AnalysisFacts.cs 20 Код проверяет, что переданный параметр langVersion не равен null. Стало быть, разработчик предполагал, что при вызове действительно может быть передан null. Спасает ли проверка от выбрасывания исключения? Увы, если переменная langVersion будет равна null, то значение первой части выражения будет равно false. При вычислении же второй части будет выброшено исключение. Как правило, фрагменты кода для статьи приходится дополнительно форматировать для улучшения читаемости. Этот случай не исключение – выражение, рассмотренное ранее, на самом деле, было записано в одну строку:  Учитывая комментарий, можно легко понять, что здесь либо перепутали приоритеты операторов, либо попросту поставили скобку неправильно. Скорее всего, метод должен выглядеть как-то так: На самом деле нет. Анализатор выдал примерно 5 сотен срабатываний на весь проект, и интересных среди них осталось немало. Поэтому я всё же предлагаю вам самостоятельно попробовать PVS-Studio и поглядеть, что новенького он ещё найдёт в этом или других проектах. Кто знает, быть может, вы сможете обнаружить какие-нибудь ошибки, которые будут даже интереснее, чем всё, что я тут наразбирал :). Ну и, конечно же, пишите о них в комментарии. Возможно, найденные вами баги даже попадут в Топ-10 ошибок 2021!  Желаю удачи! Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikita Lipilin. Is PHP compilable?! PVS-Studio searches for errors in PeachPie. Разработчик, руководитель, прокрастинатор