Всем привет, меня зовут Денис Лимарев, я разработчик платежной системы Delivery Club. И сегодня я расскажу, как мне надоели однообразные ошибки и собственная невнимательность, и как я с этим борюсь. Недавно я написал статью о нашем линтере, где вскользь затрагивал возможность написания локальных проверок под конкретный проект. Сегодня раскрою эту тему подробнее и опишу приемы, упрощающие проверку кода мне и коллегам. А в конце статьи расскажу, как можно автоматизировать некоторые проверки ИБ из нашей недавней статьи, поделюсь дальнейшими планами по развитию по развитию и приглашу послушать доклад автора go-ruleguard (далее ruleguard).
Анализ синтаксиса здесь и сейчас
Во-первых, давайте ответим на вопрос «Зачем это нужно?». Как правило, в каждой компании, команде или даже проекте складываются свои требования, которые стоит учитывать, и зачастую о них знают далеко не все, особенно если это проект чужой команды. А их нужно учитывать при проверке новых изменений. Мы все люди, и рано или поздно ошибаемся, поэтому хотелось бы такие проверки отдать машине. Да и мало у кого есть желание писать одни и те же замечания из PR в PR. Отсюда возникает потребность в инструменте, с помощью которого можно было бы быстро проверить проект и сообщить о нарушении требований. Тут вам на помощь придет ruleguard. Правила для него можно написать быстро, для этого не требуется знать go/ast и алгоритмы его обхода, что упрощает создание и поддержку таких проверок. Также ruleguard поддерживают golangci-lint, go-critic, dcRules (далее линтеры), что упрощает интеграцию в CI/CD и другие этапы проверок.
Но об этом я уже писал в прошлой статье, а сегодня расскажу про неочевидные приемы написания правил.
Необходимый минимум, чтобы писать эффективные ruleguard-правила:
-
знать Go-синтаксис;
-
знать виды переменных ruleguard;
-
немного хитрости;
-
желание не писать одни и те же комментарии к PR.
Краткий обзор видов переменных ruleguard:
-
$_
— захватываем обращение, не сохраняя имя; -
$arg
— захватываем обращение с сохранением в переменнуюarg
; -
$*_
— захватываем всю область, например, параметры через запятую или кусок кода в несколько строк, не сохраняя; -
$*args
— захватываем всю область с сохранением в переменнуюargs
.
Краткое описание методов, используемых в правилах ruleguard:
-
Match
: здесь мы описываем шаблон правила, который будет искать фрагменты кода. Метод обязателен к использованию в правиле. -
Where
: после нахождения необходимых фрагментов кода метод позволяет отфильтровать ненужные участки по внутренней информации (см. следующий абзац). Метод необязателен. -
Report
: используется для вывода сообщения об ошибке. Метод необязателен только в случае использованияSuggest
, в остальных случаях обязателен. -
Suggest
: используется для автозамены кода (см. третий прием ниже). Метод необязателен. -
At
: ограничивает область примененияSuggest
иReport
конкретным выражением (см. четвертый прием ниже). Метод необязателен.
Захваченные фрагменты кода: что мы о них знаем?
При поиске необходимых шаблонов кода вы, возможно, выделите какие-то фрагменты, например, переменные, аргументы, названия функций или методов, участки кода после каких-то выражений. Если искомые фрагменты были выделены в именованные переменные, то ruleguard хранит о них такую информацию:
-
Текстовое представление.
-
Типы
user-defined
иunderlying
, например:type YourType int
, гдеYourType
—user-defined
, аint
—underlying
. -
Область видимости: сейчас вы можете узнать, принадлежит ли выделенный фрагмент к глобальной области видимости пакета.
-
Адресуемый ли это тип.
-
Номер строки в коде.
-
Размер структуры.
-
И многое другое: приводим ли тип к другому типу, реализует ли он интерфейс, название файла c фрагментом кода, импортирует ли пакет в библиотеку и т.д.
Теперь, когда у нас есть вся необходимая вводная информация, мы готовы разобрать приемы написания правил. К каждому описанному ниже правилу я дам пояснения в тексте или комментариях к коду.
Прием первый: есть ли в Go точка с запятой в конце выражения?
m.Match(`time.Now();`)
Здесь захватывается вызов функции пакета time
. Мы четко обозначаем тот факт, что после вызова должно идти следующее выражение, поэтому отбрасываем варианты, при которых результат функции передается в качестве аргумента. Так происходит потому, что Go при сборке кода на этапе парсинга файлов самостоятельно подставляет точку с запятой в конец выражения. Правила ruleguard (да и многих других линтеров) применяются к результатам работы Go-парсера. Таким образом, можно отбросить вложенные выражения или обозначить их последовательность.
Прием второй: как проверить только определенные методы пакета?
m.Match(`time.Now()`, `time.Since($_)`)
Здесь мы ищем вызовы пакета time
, а именно Now
и Since
. При этом мы не обращаем внимание на передаваемые методу Since
аргументы.
Прием третий: автоисправление кода
Иногда для исправления ошибки достаточно тривиального изменения кода. Например, подставить один символ или заменить вызов одной функции на другую. В таких случаях вы можете захотеть добавить автоисправление кода линтером, и ruleguard это поддерживает. Чтобы сделать автоисправление, нужно добавить в правило метод Suggest
:
func sprintfConcat(m dsl.Matcher) { m.Match(`fmt.Sprintf("%s.%s", $x, $y)`). Where(m["x"].Type.Is(`string`) && m["y"].Type.Is(`string`)). Suggest(`$x + "." + $y`) }
Здесь выбираются все вызовы fmt.Sprintf
, в которых обе переменные являются строкой, а шаблон форматирования — это конкатенация двух строк через точку.
Примечание: для корректной работы Suggest
необходимо именовать все захваченные фрагменты кода, к которым будет применено автоисправление; в нашем примере это: $x
, $y
.
Прием четвертый (пункт первый): что делать, если не уверен в правиле?
Если нет полной уверенности в правиле или хочется защитить его корректность от дальнейших изменений, то можно написать тест. Для этого вам понадобится написать обычный Go-тест:
import ( "testing" "github.com/quasilyte/go-ruleguard/analyzer" "golang.org/x/tools/go/analysis/analysistest" ) func TestRules(t *testing.T) { testdata := analysistest.TestData() err := analyzer.Analyzer.Flags.Set("rules", "rules.go") if err != nil { t.Fatalf("set rules flag: %v", err) } analysistest.Run(t, testdata, analyzer.Analyzer, "./...") }
После чего напишите тестовый код, на котором возможны или невозможны срабатывания ваших правил, положите его рядом с файлом теста в папку testdata и убедитесь в корректности работы правил.
Содержание правила в rules.go
:
func strconv(m dsl.Matcher) { // Здесь мы ищем все вызовы fmt.Sprint/Sprintf, которые могут быть заменены на strconv.Itoa. m.Match(`fmt.Sprintf("%d", $x)`, `fmt.Sprintf("%v", $x)`, `fmt.Sprint($x)`). Where(m["x"].Type.Is(`int`)). Suggest(`strconv.Itoa($x)`) }
Тестовый код в positive.go
:
func Warn() { var i int _ = fmt.Sprintf("%v", i) // want `suggestion: strconv.Itoa(i)` println(fmt.Sprintf("%s", i))// want `suggestion: strconv.Itoa(i)` }
При обнаружении подходящих фрагментов кода ruleguard выдаст предупреждение, которое будет прочитано библиотекой go/analysis. Чтобы отметить участок тестового кода, для которого необходимо вывести предупреждение, справа от искомой строки напишите служебный комментарий формата // want
: "test warning"
, где "test warning"
— текст предупреждения. Полный рабочий пример теста и тестовых данных можно посмотреть здесь.
Примечание: текст предупреждения может быть описан в косых или двойных кавычках.
Прием четвертый (пункт второй): что делать, если не уверен в автоисправлении?
Напомню, что помимо обнаружения шаблонов кода линтеры могут делать автоисправление, и, к счастью, его тоже можно проверить. Для этого необходимо выполнить шаги из приема выше. Рядом с тестируемым кодом добавьте копию с исправлениями в отдельном файле c суффиксом .golden
.
Содержание правила в rules.go
:
func rangeExprCopy(m dsl.Matcher) { // Здесь мы ищем все массивы, у которых размер элементов больше 256 байт. m.Match(`for $_, $_ := range $x { $*_ }`, `for $_, $_ = range $x { $*_ }`). Where(m["x"].Type.Is("[$_]$_") && m["x"].Type.Size >= 256). At(m["x"]). Suggest(`&$x`) }
Примечание: At
ограничивает функции Suggest
и Report
конкретным выражением из всего выбранного фрагмента кода. В данном случае это массив в $x
.
Тестовый код в positive.go
:
func warn() { var xs [42]byte for _, x := range xs { // want `suggestion: &xs` println(x) } }
Эталонный код в positive.go.golden
:
func warn() { var xs [42]byte for _, x := range &xs { println(x) } }
Примечание: эталонный код не должен содержать служебный комментарий, используемый линтером.
После этого добавьте новый тест, идентичный предыдущему, изменив только последнюю строку c analysistest.Run
на analysistest.RunWithSuggestedFixes
:
import ( "testing" "github.com/quasilyte/go-ruleguard/analyzer" "golang.org/x/tools/go/analysis/analysistest" ) func TestRules(t *testing.T) { testdata := analysistest.TestData() err := analyzer.Analyzer.Flags.Set("rules", "rules.go") if err != nil { t.Fatalf("set rules flag: %v", err) } analysistest.RunWithSuggestedFixes(t, testdata, analyzer.Analyzer, "./...") }
Полный рабочий пример теста и тестовых данных для автоисправления можно посмотреть здесь и здесь.
Прием пятый: захватываем все функции и свойства
Когда нам не важны аргументы или какая-то информация о функции, мы можем это отбросить с помощью $_
, например так:
m.Match(`for $_, $_ := range $_ { $*_; defer $_; $*_ }`)
Здесь мы ищем случаи, когда в цикле вызывается выражение defer
и нам не важно, в результате чего мы получаем данные для итерирования.
Прием шестой: мне не важны аргументы, кроме одного
Бывают ситуации, когда вам важен только один аргумент из списка переданных. Например, функция принимает тип variadic
, или заранее неизвестно, какое количество аргументов будет принимать функция, или вам просто лень всё перечислять, такое тоже бывает. В таком случае вы можете отбросить все аргументы или переменные и искать только нужный:
Захватываем все переменные с типом error
, которые создаются через присвоение :=
:
m.Match(`$*_, $err := $_;`). Where(m["err"].Type.Is("error")
Захватываем все аргументы с типом map
, где нам не важен тип ключа и значения:
m.Match(`$_($*_, $map, $*_);`). Where(m["map"].Type.Underlying.Is(`map[$_]$_`)
Примечание: если нам были бы необходимы хэш-таблицы (далее просто таблицы), имеющие в качестве ключа или значения только определенные типы, то выражение могло бы выглядеть иначе, например:
-
map[string]error
: будут отобраны только таблицы с данным типом; -
map[int]$_
: будут отобраны все таблицы с ключомint
и любым типом в качестве значения; -
map[$_]int
: будут отобраны таблицы с любым ключом и значением с типомint
.
Прием седьмой: ищем ошибки стиля именования
Так как ruleguard хранит текстовое представление фрагментов кода, то можно искать и проверять стиль именований, а также делать другие проверки стиля. Рассмотрим на примере проверки функций:
m.Match( `func $x($*_) $*_ { $*_ }`, // выборка обычных функций `func ($_) $x($*_) $*_ { $*_ }`, // выборка методов структур `func ($_ $_) $x($*_) $*_ { $*_ }` // выборка методов структур с receiver ). Where(!m["x"].Text.Matches(`^_$`) && (m["x"].Text.Matches(`-`) || m["x"].Text.Matches(`_`))). // находим все строки, содержащие подчеркивания или тире Report("use camelCase naming strategy")
Прием восьмой: уменьшаем размер правил — пишем функции
Правила для ruleguard выглядят как код на Go, но им не являются. На самом деле они написаны на DSL, который интерпретируется библиотекой go-grep, поэтому они не обладают всеми возможностями Go. Тем не менее ruleguard поддерживает анонимные функции для упрощения и компактности правил. Рассмотрим на примере упрощения записи данных в интерфейс io.Writer
:
func writeBytes(m dsl.Matcher) { isBuffer := func(v dsl.Var) bool { return v.Type.Is(`bytes.Buffer`) || v.Type.Is(`*bytes.Buffer`) } m.Match(`io.WriteString($w, $buf.String())`). Where(isBuffer(m["buf"])). Suggest(`$w.Write($buf.Bytes())`) m.Match(`io.WriteString($w, string($buf.Bytes()))`). Where(isBuffer(m["buf"])). Suggest(`$w.Write($buf.Bytes())`) }
Здесь мы ищем все вызовы io.WriteString
, в которых тип переменной $buf
— bytes.Buffer
. Для уменьшения размера правила оборачиваем проверку типа в анонимную функцию. Также можно заметить автоисправление кода.
Прием девятый: подключаем сторонние бандлы с правилами
На данный момент есть много сторонних бандлов с правилами ruleguard, которые можно подключить в дополнение к своим (см. полезные ссылки ниже). Сделать это достаточно просто:
import ( "local/pkg/rules" dcRules "github.com/delivery-club/delivery-club-rules" "github.com/quasilyte/go-ruleguard/dsl" ) func init() { dsl.ImportRules("external-dc-rules", dcRules.Bundle) // первый аргумент функции - префикс, который будет добавляться при импортировании // правил бандла, полезен, когда названия ваших правил пересекаются с названиями // правил в стороннем бандле dsl.ImportRules("local-dc-rules", rules.Bundle) }
Файл с таким содержимым нужно выделить в отдельный пакет внутри проекта, далее абсолютный путь до файла можно использовать в линтерах. Пример проекта с использованием локальных и импортируемых бандлов.
Прием десятый: решаем проблему импортов сторонних библиотек
Описанная ниже ситуация возможна только при написании бандлов в отдельном репозитории. Ruleguard может импортировать стороннюю библиотеку, чтобы написать правила для нее. Для этого в правиле необходимо вызвать метод Import
, например:
import "github.com/quasilyte/go-ruleguard/dsl" func queryWithoutContext(m dsl.Matcher) { m.Import("github.com/jmoiron/sqlx") m.Match(`$db.Queryx($*_)`). Report(`don't send query to external storage without context`) }
Здесь мы ищем вызовы методов sqlx
без контекста. Но если библиотеки github.com/jmoiron/sqlx в проверяемом проекте нет, то линтер упадет, так как не сможет ее подгрузить в рантайме. Для решения этой проблемы можно создать пакет с интерфейсами для сторонней библиотеки в проекте бандла, и после этого импортировать не стороннюю библиотеку, а интерфейсы для нее. Правило станет выглядеть так:
import ( "github.com/quasilyte/go-ruleguard/dsl" _ "github.com/delivery-club/delivery-club-rules/pkg" ) func queryWithoutContext(m dsl.Matcher) { m.Import("github.com/delivery-club/delivery-club-rules/pkg") // пример расположения внутреннего пакета m.Match(`$db.Queryx($*_)`) Where(m["db"].Object.Is("Var") && m["db"].Type.Implements(`pkg.SQLDb`)). Report(`don't send query to external storage without context`) }
В последнем правиле делается безымянный Go-импорт пакета с интерфейсами для избежания проблем с Go vendoring. Затем вызывается импорт в правиле, чтобы можно было работать с пакетом.
Примечание: напомню, код правил не является кодом на Go, поэтому им нужен отдельный импорт.
В Where
мы проверяем, что найденные вызовы являются методами переменной, а переменная реализует необходимый интерфейс, где pkg.SQLDb
— интерфейс, который описывает методы библиотеки sqlx.
Прием одиннадцатый: ищем аргументы
m.Match(`fmt.Sprintf($*_)`). Where(m["$$";].Node.Parent().Is(`ExprStmt`))
Здесь мы ищем все вызовы функции fmt.Sprintf
, вложенные в другие вызовы выражений. $$
обозначает предшествующее выражение, а проверка на соответствие ExprStmt
— факт того, что выражение принимает результат работы функции.
Прием двенадцатый: теги для правил
Правила ruleguard поддерживают разделение по тегам, что позволяет выполнять только нужные группы правил без их явного перечисления. Это полезно при работе с локальными для проекта правилами и при разработке наборов правил. С помощью тегов можно фильтровать экспериментальные, стилевые, медленные правила и так далее. Добавить теги легко, для этого перед объявлением правила необходимо описать комментарий в форме //doc:tags:
, без пробела после //
:
//doc:summary Detects Before call of time.Time that can be simplified. //doc:tagsstyle experimental //doc:before !t.Before(tt) //doc:after t.After(tt) func timeComparisonSimplify(m dsl.Matcher) { isTime := func(v dsl.Var) bool { return v.Type.Is(`time.Time`) || v.Type.Is(`*time.Time`) } m.Match(`!$t.Before($tt)`). Where(isTime(m["t"])). Suggest("$t.After($tt)") }
Здесь мы ищем все вызовы Before
типом time.Time
, перед которыми стоит отрицание.
Для фильтрации правил по тегам при выполнении через golangci-lint необходимо в конфигурации go-critic в блоке для ruleguard явно описать теги, которые должны быть включены или выключены. Это можно сделать в соответствующих полях enable
, disable
c знаком #
перед каждым названием тега.
Пример конфигурации .golangci.yml
:
linters-settings: gocritic: settings: ruleguard: enabled: '#diagnostic,#performance'
Фильтрация тегов была добавлена недавно и на текущий момент поддерживается только последними версиями go-critic (>=v0.6.3) и мастером golangci-lint — dcRules.
Примечание: помимо тегов есть и другие служебные комментарии: summary
, before
и after
. Основная их цель — объяснить смысл правила и суть изменений, к которым приведет его соблюдение.
Известные особенности, о которых стоит знать:
-
Не все Go-переменные имеют тип. Это связано с особенностью использования
_
— этот вид переменной не имеет типа. Поэтому когда вы проверяете переменную на принадлежность к типу или реализации интерфейса, правило может не сработать. Issue. -
Применение регулярных выражений к большим фрагментам кода может сильно замедлить проверку правил. Старайтесь по возможности добавлять дополнительные условия в
Where
перед вызовом регулярных выражений. -
Если вы импортируете пакет, которого нет в проекте, то линтер завершит выполнение с ошибкой. Issue.
-
Пока что нельзя проверить все переменные, константы или типы, если они записаны в сгруппированной форме. Issue.
Заключение
На данный момент ruleguard умеет многое, и библиотека продолжает развиваться. Недавно вышла новая версия go-critic (0.6.3), которая была уже добавлена в master golangci-lint; помимо новых проверок была также добавлена улучшенная функциональность ruleguard (~v0.3.16). Надеюсь, вам пригодится.
Тем временем силами @quasilyte и при поддержке open source-сообщества (в том числе при моем участие) дальше прорабатывается возможность написания более гибких правил, а именно:
-
Интерпретатор quasigo для возможности написания более широкого спектра условий.
-
Добавление локальных интерфейсов, чтобы уйти от необходимости писать отдельный пакет под интерфейсы, как это сделано в шестом приеме. Issue.
-
Обработка отсутствующих импортов в правилах для уменьшения случаев завершения выполнения с ошибкой. Issue.
-
Поддержка SSA для возможности написания более широкого спектра правил. Issue.
-
Добавление возможности блокировать конкретное правило, а не только весь линтер в golangci-lint. Issue.
Планы по развитию линтеров, в том числе из прошлой статьи, которые были реализованы к текущему дню:
-
Добавление возможности быстрого исправления кода через ruleguard: реализовано в golangci-lint 1.44.0 (PR).
-
Добавление возможности игнорирования конкретных ruleguard-правил в конфиге: реализовано в go-critic 0.6.2 (PR) и golangci-lint 1.44.0.
-
Добавлена возможность игнорировать правила по тегам ruleguard: реализовано в go-critic 0.6.3 (PR) и в мастере golangci-lint.
-
Помощь в тестировании и реализации submatch-выражений ruleguard (метод Contains): добавлено в go-critic 0.6.3 (PR), в golangci-lint обновление в мастере, ждем следующего релиза (>1.45.2).
-
Добавлена возможности компилировать линтер: реализовано в dcRules 0.7.0 (PR).
-
В ruleguard добавлена возможность проверять область видимости переменной (PR).
Мы окунулись в основы синтаксического анализа с помощью ruleguard, научились искать базовые шаблоны кода и писать под них тесты, рассмотрели виды переменных ruleguard и предпосылки для синтаксического анализа в вашем проекте. Надеюсь, описанные приемы помогут вам в поиске ошибок, стилевых неточностей, неоптимальных участков кода и многого другого.
Полезные ссылки
-
Реализация некоторых проверок ИБ из статьи коллег.
Послесловие
Смотрите запись митапа в VK Tech Talks за 14 апреля: Искандер (он же @quasilyte) выступил с докладом про Go-интерпретатор quasigo в ruleguard: если коротко, то интерпретатор позволит нам еще лучше оградить себя от случайных ошибок. Как небольшой спойлер, скажу, что сейчас на бенчмарках интерпретатор обгоняет такие популярные решения на Go, как yaegi и scriggo.
ссылка на оригинал статьи https://habr.com/ru/company/deliveryclub/blog/661631/
Добавить комментарий