Вебинар: Тимлид: ожидания, реальность и внутренние вопросы - 15.04
На нём написан Docker, Kubernetes, Gitea и многие другие проекты самых разных масштабов. Наверное, вы догадались, что речь идёт о Go. Мы никогда не писали об ошибках в Golang-проектах, но настало время это исправить, ведь скоро выйдет анализатор PVS-Studio для Go!

Статические анализаторы являются довольно распространёнными инструментами в разработке. В Golang есть встроенный механизм статического анализа — go vet. Однако стандартные линтеры не всегда справляются. Для тех, кто с нами не знаком, мы — компания PVS-Studio, занимаемся разработкой одноименного статического анализатора для C, C++, C# и Java. В последнее время мы активно занимаемся разработкой анализатора для Go и уже скоро планируем выпустить открытую бета-версию.
Мы уже писали статью про то, как сделать свой статический анализатор на Go. И теперь наступило время показать результат работы нашего анализатора на реальных проектах.
Это статья немного необычная, поскольку мы впервые пишем об ошибках в проектах, написанных на Go. В этой статье будут описаны некоторые диагностические правила Golang анализатора, а также будут приведены их срабатывания на реальных проектах.
Любой анализатор PVS-Studio, будь то C, C++, C#, Java или Go, состоит из диагностических правил. Именно диагностические правила определяют логику тех или иных срабатываний анализатора.
Прежде чем приступить к обзору ошибок, давайте я вам немного расскажу, как же у нас выглядит процесс разработки диагностических правил.
Прежде всего мы ищем идеи для диагностик (читаем форумы, кодовые базы открытых проектов, обрабатываем обратную связь от клиентов) и исследуем паттерны ошибок.
Затем разработчик получает задание на реализацию определённой диагностики и проводит собственное исследование: в каких ситуациях анализатор должен ругаться, а в каких случаях этого делать не стоит. Затем идёт написание множества позитивных (когда анализатор должен ругаться) и негативных (когда ругаться НЕ надо) тестов. Ну и, само собой, реализация самого диагностического правила.
Казалось бы, всё, но не так-то просто. Те тесты, которые написал разработчик и на которые опирался при реализации, являются синтетическими, и на реальных проектах ситуация может выглядеть совсем иначе. Поэтому требуется проверить диагностическое правило на реальных проектах, поэтому мы проводим анализ большого количества открытых проектов с GitHub. В зависимости от полученных срабатываний разработчик либо дорабатывает диагностическое правило, либо отправляет код на ревью.
Так к чему я это всё? К тому, что все срабатывания, описанные в этой статье, как раз-таки взяты из реальных проектов.
Давайте перейдём к обзору.
Диагностика V8001 сообщает о наличии одинаковых подвыражений в бинарном выражении. Эта проблема может возникнуть из-за copy-paste и может быть обнаружена с помощью встроенного линтера go vet. Например:
func rgb1(r float32, g float32, b float32) {
if r > 1 || g > 1 || r > 1 {
....
}
}
В этом случае стандартный инструмент напишет нам redundant or: r > 1 || r > 1. Однако если мы поменяем местами 1 и r, а также сменим оператор сравнения, то получим такой код:
func rgb(r float32, g float32, b float32) {
if r > 1 || g > 1 || 1 < r {
....
}
}
И на такой код стандартный go vet ругаться уже не будет, хотя проблема осталась: подвыражение r > 1, по сути, эквивалентно подвыражению 1 < r.
Небольшой спойлер: все потенциальные ошибки в статье нельзя найти с помощью встроенного go vet.
Рассмотрим пример подобной ошибки на проекте Nuclei. Это сканер уязвимостей, который позволяет создавать кастомные шаблоны.
func NewEntityParser(dir string) (*EntityParser, error) {
cfg := &packages.Config{
Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports |
packages.NeedTypes | packages.NeedSyntax | packages.NeedTypes |
packages.NeedModule | packages.NeedTypesInfo,
Tests: false,
Dir: dir,
ParseFile: func(....) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
}
Предупреждение PVS-Studio: V8001 There are identical sub-expressions 'packages.NeedTypes' to the left and to the right of the '|' operator. parser.go 33
Здесь лишний раз повторяется флаг packages.NeedTypes. Возможно, ошибка произошла из-за автоподстановки кода, и на самом деле тут должен быть флаг packages.NeedTypesSizes:
const (
....
// NeedTypes adds Types, Fset, and IllTyped.
NeedTypes
// NeedSyntax adds Syntax and Fset.
NeedSyntax
// NeedTypesInfo adds TypesInfo and Fset.
NeedTypesInfo
// NeedTypesSizes adds TypesSizes.
NeedTypesSizes
...
)
Казалось бы, ошибка примитивная, но с помощью стандартного go vet найти её не получится.
В проекте медиасервера и медиапрокси Mediamtx с помощью анализатора был найден следующий фрагмент кода:
func (p *Core) closeResources(....) {
....
closeRTSPServer := newConf == nil ||
newConf.RTSP != p.conf.RTSP ||
....
newConf.RTSPAddress != p.conf.RTSPAddress || // <=
....
newConf.RTSPUDPReadBufferSize != p.conf.RTSPUDPReadBufferSize ||
newConf.UDPReadBufferSize != p.conf.UDPReadBufferSize ||
newConf.ReadTimeout != p.conf.ReadTimeout ||
newConf.WriteTimeout != p.conf.WriteTimeout ||
newConf.WriteQueueSize != p.conf.WriteQueueSize ||
newConf.RTPAddress != p.conf.RTPAddress ||
newConf.RTCPAddress != p.conf.RTCPAddress ||
newConf.MulticastIPRange != p.conf.MulticastIPRange ||
newConf.MulticastRTPPort != p.conf.MulticastRTPPort ||
....
newConf.RTSPAddress != p.conf.RTSPAddress || // <=
....
newConf.RunOnConnect != p.conf.RunOnConnect ||
newConf.RunOnConnectRestart != p.conf.RunOnConnectRestart ||
newConf.RunOnDisconnect != p.conf.RunOnDisconnect ||
closeMetrics ||
closePathManager ||
closeLogger
}
Предупреждение PVS-Studio: V8001 There are identical sub-expressions 'newConf.RTSPAddress != p.conf.RTSPAddress' to the left and to the right of the '!=' operator. core.go 790
Смогли бы вы сами найти глазами подобную ошибку, ещё и в сокращённом примере? Код-ревью, безусловно, мощный способ поиска ошибок, но его не получится применять абсолютно везде. И ладно бы только это, но встроенный инструмент тоже не справляется!
Похожая ошибка нашлась и в открытом проекте Tidb:
func ProcessModifyColumnOptions(....) error {
var sb strings.Builder
restoreFlags := format.RestoreStringSingleQuotes |
format.RestoreKeyWordLowercase |
format.RestoreNameBackQuotes |
format.RestoreSpacesAroundBinaryOperation |
format.RestoreWithoutSchemaName | // <=
format.RestoreWithoutSchemaName // <=
restoreCtx := format.NewRestoreCtx(restoreFlags, &sb)
....
}
Предупреждение PVS-Studio: V8001 There are identical sub-expressions 'format.RestoreWithoutSchemaName' to the left and to the right of the '|' operator. modify_column.go 2073
Несмотря на то, что это достаточно популярный проект (40 тыс. звёзд на GitHub), всё равно в кодовой базе встречаются тривиальные ошибки.
В этом проекте два срабатывания с одинаковой ошибкой:
func (s *Store) GetStorePluginManifests(....) []StorePluginManifest {
....
for _, manifest := range pluginManifest {
existingManifest, found := lo.Find(storePluginManifests,
func(manifest StorePluginManifest) bool {
return manifest.Id == manifest.Id
})
if found {
....
}
storePluginManifests = append(storePluginManifests, manifest)
}
}
}
Предупреждение PVS-Studio: V8001 There are identical sub-expressions 'manifest.Id' to the left and to the right of the '==' operator. store.go 100
Очевидно, что сравнивать manifest.Id с самим собой не имеет смысла. В нашем случае имя manifest используется как для итерирования по pluginManifest:
for _, manifest := range pluginManifest { .... }
Так и в качестве параметров для функции:
func(manifest StorePluginManifest) bool { .... }
Предполагалось сравнивать поле Id у элементов из pluginManifest с тем же полем параметра анонимной функции. Но получилось, что сравниваются одинаковые поля одного и того же параметра:
func(manifest StorePluginManifest) bool {
return manifest.Id == manifest.Id
}
Такая же ошибка в другом месте:
func (s *Store) GetStoreThemes(ctx context.Context) []common.Theme {
....
for _, manifest := range themeManifest {
_, found := lo.Find(storeThemeManifests,
func(manifest common.Theme) bool {
return manifest.ThemeId == manifest.ThemeId // <=
})
if found {
//skip duplicated theme
continue
}
storeThemeManifests = append(storeThemeManifests, manifest)
}
....
}
Предупреждение PVS-Studio: V8001 There are identical sub-expressions 'manifest.ThemeId' to the left and to the right of the '==' operator. store.go 72
Стоит отметить, что к моменту выхода этой статьи ошибки успели исправить в этом коммите и в этом. Хотя, судя по истории изменений, ошибки продержались около трёх лет.
Таких ошибок можно полностью избежать, если использовать статический анализ регулярно, например, в CI/CD системах как quality gate перед каждым коммитом.
Большинство ошибок, связанных со строками формата, заключается в несовпадении количества аргументов с количеством плейсхолдеров. Однако в проекте Gitea была обнаружена следующая интересная ошибка:
func DropTableColumns(...) (err error) {
switch {
....
case setting.Database.Type.IsMSSQL():
....
for _, constraint := range constraints {
if _, err := sess.Exec(fmt.Sprintf("DROP INDEX `%[2]s` ON `%[1]s`",
tableName, constraint)); err != nil {
return fmt.Errorf("Drop index `%[2]s` on `%[1]s`: %v",
tableName, constraint, err)
}
}
....
}
....
}
Предупреждение PVS-Studio: V8013 Incorrect format. A different number of format items is expected while calling the 'fmt.Errorf' function. Expected: 2, present: 3. db.go 469
Давайте присмотримся к строке формата:
"Drop index `%[2]s` on `%[1]s`: %v"
Казалось бы, всё хорошо и количество аргументов совпадает с количеством плейсхолдеров в строке. Предполагается, что %[2]s будет соответствовать переменной constraint, поскольку имеется индекс [2], %[1]s — соответствовать tableName, и последнее оставшееся %v — переменной err. Но на самом деле вместо err для %v будет подставлено значение constraint.
Почему так происходит?
В Golang можно явно указать номер аргумента, который должен подставляться в конкретный плейсхолдер. Важно отметить, что следующий номер аргумента для подстановки вычисляется исходя из номера предыдущего аргумента подстановки, за исключением первого аргумента. Следовательно, если явно указать, что должен подставляться аргумент n, то следом за ним будет подставлен аргумент n + 1.
В этом случае %[1]s указывает на первый аргумент tableName и неявно переносит номер аргумента для следующего плейсхолдера на 1 от текущего, то есть второй аргумент constraint вместо задуманного err.
Паттерн if A { } else if A { } зачастую является copy-paste ошибкой, поскольку в таком случае второе условие A всегда будет ложным при его проверке и, следовательно, код внутри then-ветки никогда не будет выполнен.
Такую ситуацию можно обнаружить в проекте Glance:
func parseCliOptions() (*cliOptions, error) {
var args []string
args = os.Args[1:]
if len(args) == 0 {
intent = cliIntentServe
} else if len(args) == 1 {
....
} else if len(args) == 2 {
if args[0] == "password:hash" {
intent = cliIntentPasswordHash
} else {
return nil, unknownCommandErr
}
} else if len(args) == 2 {
if args[0] == "mountpoint:info" {
intent = cliIntentMountpointInfo
} else {
return nil, unknownCommandErr
}
} else {
return nil, unknownCommandErr
}
}
Предупреждение PVS-Studio: V8004 The use of 'if A {...} else if A {...}' pattern was detected. Potential logical error is present. Check lines: 92, 86. cli.go 92
Здесь снова copy-paste: thenветки обоих if очень похожи, но, вероятнее всего, тут сделали копипаст и забыли поменять второе условие.
Аналогичную ошибку можно найти в кодовой базе фреймворка Havoc:
var (
Status = Parser.ParseInt32()
Message = make(map[string]string)
)
logger.Debug(fmt.Sprintf(....))
if Status == INJECT_ERROR_SUCCESS {
Message["Type"] = "Good"
Message["Message"] = "Successful injected shellcode"
} else if Status == INJECT_ERROR_FAILED { // <=
Message["Type"] = "Error"
Message["Message"] = "Failed to inject shellcode"
} else if Status == INJECT_ERROR_INVALID_PARAM {
Message["Type"] = "Error"
Message["Message"] = "Invalid parameter specified"
} else if Status == INJECT_ERROR_PROCESS_ARCH_MISMATCH {
Message["Type"] = "Error"
Message["Message"] = "Process architecture mismatch"
} else if Status == INJECT_ERROR_FAILED { // <=
Message["Type"] = "Error"
Message["Message"] = "Failed to inject shellcode"
}
Предупреждение PVS-Studio: V8004 The use of 'if A {...} else if A {...}' pattern was detected. Potential logical error is present. Check lines: 3637, 3628. demons.go 3637
Во фрагменте кода условие и then-ветки if-ов совпадают, поэтому, возможно, что это лишь избыточный код. Но также есть вероятность, что вместо второго INJECT_ERROR_FAILED должно быть что-то другое, и сейчас часть логики программы попросту не работает.
Стандартный линтер не даёт написать несколько case с повторяющимися выражениями, но иногда не справляется.
Например, здесь стандартный инструмент будет ругаться:
const (
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
)
switch day {
case MONDAY:
....
case TUESDAY:
....
case WEDNESDAY:
....
case THURSDAY:
....
case MONDAY:
....
}
MONDAY используется дважды в качестве выражения для case.
Но уже здесь go vet ошибки не найдёт:
switch {
case day == MONDAY:
....
case day == TUESDAY:
....
case day == WEDNESDAY:
....
case day == THURSDAY:
....
case day == MONDAY:
....
}
В свою очередь, диагностическое правило V8010 анализатора PVS-Studio учитывает различные перестановки внутри бинарных выражений.
Рассмотрим срабатывания этого правила на реальных проектах.
В коде популярного блокировщика рекламы AdGuardHome была найдена следующая потенциальная ошибка:
func validateVersion(current, target uint) (err error) {
switch {
case current > target:
return fmt.Errorf("unknown current schema version %d", current)
case target > LastSchemaVersion:
return fmt.Errorf("unknown target schema version %d", target)
case target < current:
return fmt.Errorf("target schema version %d lower than current %d",
target, current)
default:
return nil
}
}
Предупреждение PVS-Studio: V8010 Two or more case branches have equivalent expressions. migrator.go 95
Очень подозрительный код. На первый взгляд кажется, что произошла путаница из-за оператора присваивания, и в последнем кейсе должно быть target > current. Но, судя по строке "target schema version %d lower than current %d" внутри fmt.Errorf в теле этого кейса, условие написано правильно.
Поэтому, скорее всего, неправильно написано условие для первого case, что очень странно, поскольку обычно наоборот: сначала пишут правильно, а потом ошибаются (так называемый эффект последней строки).
Диагностическое правило V8011 сообщает, что слева и справа от составного оператора присваивания используется одна и та же переменная. Это может означать, что разработчик использовал составной оператор присваивания (например, +=), а затем в правой части написал ту же переменную, к которой происходит присваивание, но так, будто используется обычный оператор присваивания =. Например:
x -= x - 2
Очевидно, что нет смысла вычитать из x самого себя и можно сразу написать так:
x = 2
Но, скорее всего, здесь ошибка, и разработчик хотел вычесть из x значение 2.
Подобный простейший пример можно найти в кодовой базе проекта Gitea:
func (s *linkifyParser) Parse(....) ast.Node {
....
if m != nil {
lastChar := line[m[1]-1]
if lastChar == '.' {
m[1]--
} else if lastChar == ')' {
....
} else if lastChar == ';' {
i := m[1] - 2
for ; i >= m[0]; i-- {
if util.IsAlphaNumeric(line[i]) {
continue
}
break
}
if i != m[1]-2 {
if line[i] == '&' {
m[1] -= m[1] – i // <=
}
}
}
}
....
}
Предупреждение PVS-Studio: V8011 Identical expression 'm[1]' to the left and to the right of compound assignment. linkify.go 108
Очевидно, что нет смысла вычитать из m[1] своё же значение. Скорее всего, из m[1] хотели вычесть i, но произошла опечатка и получилось что-то среднее между m[1] = m[1] – i и m[1] -= i.
В этом же проекте нашлась похожая ошибка:
func (p *Paginator) Pages() []*Page {
....
previousNum := getMiddleIdx(p.numPages) - 1
if previousNum > p.current-1 {
previousNum -= previousNum - (p.current - 1)
}
....
}
Предупреждение PVS-Studio: V8011 Identical expression 'previousNum' to the left and to the right of compound assignment. paginator.go 177
func (c *Client) InvoiceSearch(....) (....) {
uri := searchInvoice
if page != 0 && pageSize != 0 {
uri += uri + "?page=" + // <=
strconv.Itoa(page) +
"&page_size=" +
strconv.Itoa(pageSize)
}
if totalRequired {
if page != 0 && pageSize != 0 {
uri += uri + "&total_required=true" // <=
} else {
uri += uri + "?total_required=true" // <=
}
}
....
}
Предупреждения PVS-Studio:
V8011 Identical expression 'uri' to the left and to the right of compound assignment. invoice.go 362
V8011 Identical expression 'uri' to the left and to the right of compound assignment. invoice.go 366
V8011 Identical expression 'uri' to the left and to the right of compound assignment. invoice.go 368
Здесь оператор += используется для типа string. Очень странным является добавление к переменной uri самой себя.
Бывают случаи, когда скобки могут запутать. Например, как в проекте OpenDiablo2:
func (wg *WidgetGroup) adjustSize(w Widget) {
x, y := w.GetPosition()
width, height := w.GetSize()
if x+width > wg.x+wg.width {
wg.width += (x + width) - (wg.x + wg.width) // <= (1)
}
if wg.x > x {
wg.width += wg.x - x
wg.x = x
}
if y+height > wg.y+wg.height {
wg.height += (y + height) - (wg.y + wg.height) // <= (2)
}
if wg.y > y {
wg.height += wg.y - y
wg.y = y
}
}
Предупреждения PVS-Studio:
V8011 Identical expression 'wg.width' to the left and to the right of compound assignment. widget_group.go 55
V8011 Identical expression 'wg.height' to the left and to the right of compound assignment. widget_group.go 64
Здесь ошибки не так бросаются в глаза, но если раскрыть скобки, то получим:
wg.width += -wg.width + ....
И:
wg.height += -wg.height + ....
Для второго срабатывания.
В апреле 2026 года мы планируем выпустить открытую бета-версию PVS-Studio для языка Go. Для того, чтобы это не пропустить, настоятельно рекомендуем подписаться на нашу рассылку.
Дальше — больше. Мы планируем активно развивать наш Go анализатор, поэтому будем рады, если вы попробуете PVS-Studio и дадите нам обратную связь.
На этом всё. Мы рассмотрели небольшое количество срабатываний нашего анализатора на Golang-проектах и наглядно убедились в том, что ошибки, к сожалению, не обходят стороной даже крупные проекты. Поэтому важно использовать инструменты для контроля качества кодовой базы.
Отметим, что анализатор PVS-Studio можно использовать бесплатно. Если вы участвуете в разработке открытого проекта (например, тех, что упомянуты в статье), то можете получить бесплатную версию. Подробности описаны в статье про бесплатное лицензирование.
Берегите себя и свой код!
0