Golangのfor rangeでのポインタ問題をLinterで検知する | Resilire Tech Blog

2023/08/29に公開

はじめに

こんにちは、サーバーサイドエンジニアのmynkitです。

サプライチェーンリスク管理クラウドサービスResilireでは、サーバーサイドにGoを採用しています。Goはとても書きやすく個人的にも好きな言語ですが、稀にひっかかる部分があります。

今回はfor range内でのポインタの挙動で気をつけないといけないことと、これをLinterで検知する方法を紹介させていただきます。

for range内でのポインタの挙動

まず以下のコードを実行すると、結果はどうなるでしょうか。

// main.go
package main

import "fmt"

type User struct {
	id   int
	name string
}

func main() {
	users := []User{{1, "John"}, {2, "Melissa"}, {3, "Robert"}}
	var names []*string
	for _, v := range users {
		names = append(names, &v.name)
	}
	for _, v := range names {
		fmt.Println(*v)
	}
}

結果はこうなります。すべて最後のUserの値で置き換えられてしまっています。

Robert
Robert
Robert

これはfor _, v := range文の中では、毎回同じアドレスが使われていることに起因します。

対策方法

これを防ぐためには以下のように[i]でアクセスすることで対応できます。

// main_fix.go
package main

import "fmt"

type User struct {
	id   int
	name string
}

func main() {
	users := []User{{1, "John"}, {2, "Melissa"}, {3, "Robert"}}
	var names []*string
	for i := range users {
		names = append(names, &(users[i]).name)
	}
	for i := range names {
		fmt.Println(*names[i])
	}
}

出力結果

John
Melissa
Robert

Linterによる検知

今回はmain.goのような書き方になってしまっているファイルをLinterで発見する仕組みを考えます。

技術選定

GoでLinterといえば、golangci-lintが思いつくと思います。

カスタムルールを作成する方法も紹介されていますが、.soを書き出す必要があったりして、既存のプロジェクトに組み込むのは拡張性などの面で少しハードルがあります。

https://golangci-lint.run/contributing/new-linters/#how-to-add-a-private-linter-to-golangci-lint

保守性と拡張性を考えた時、goastというものが使えそうです

https://github.com/m-mizutani/goast

goast概要

以下の開発者の記事によると、

Goのコードを読み込み、コードの抽象表現であるAST(Abstract Syntax Tree、構文抽象木)をRegoで記述されたポリシーによって評価します。ASTに関する説明はこちらの資料などがわかりやすいかと思います。 parser パッケージを使ってGoソースコードのASTを取得し、これをRegoのポリシーで評価します。評価はファイル全体のASTを一度だけ渡す、あるいはASTのノード毎に評価するモードを用意しています。

とのことです。

https://zenn.dev/mizutani/articles/go-static-analysis-with-rego

そのため、Regoでカスタムルールを書いていくことになります。

Linter実装

先に結論

プロジェクトディレクトリに.goastディレクトリを作っておきます。ここの中に.regoファイルを書いておきます。

これでfor rangeのvalueのアドレスを、append関数の中で利用しようとした場合にエラーを吐き出します。

# do_not_append_for_range_value_memory_address.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [appendPath, appendValue] := walk(input.Node.Body.List)
    appendValue[_].Fun.Name == "append" # search for append func only
    [path, value] := walk(appendValue)
    value.Op == 17 # operator: &
    [pathDetail, valueDetail] := walk(value)
    valueDetail.Name == input.Node.Value.Name

    res := {
        "msg": "do not append for range value memory address",
        "pos": value.OpPos,
        "sev": "ERROR",
    }
}

次にgoastコマンドを実行するため、インストールしておきます

go install github.com/m-mizutani/goast/cmd/goast@latest

localからコマンドラインでLinterを実行する場合は以下のコマンドを実行します。

goast eval -p .goast --ignore-auto-generated **/*.go

試しに冒頭のmain.goに対して、コマンド実行すると、以下の出力が得られます。

$ goast eval -p .goast/do_not_append_for_range_value_memory_address.rego main.go
[main.go:14] - do not append for range value memory address: "&v"

		names = append(names, &v.name)
		                      ~~~~~~~~


	Detected 1 violations

GitHub Actionsを利用したい場合は、goast-actionを用意していただいているため簡単に設定できます。

# ci.yml
name: goast

on:
  pull_request:

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: run goast Lint
        uses: m-mizutani/goast-action@main
        with:
          policy: ./.goast 
          format: text 
          source: ./
          ignore_auto_generated: true

Regoファイルの作成方法

基本ルール

上記のRegoファイルの作成方法を解説していきます。

まず、goastのRegoファイルにいくつかルールがあるようです。

  • package が goast でなければならない
  • 入力: input には Path、Kind のようなメタ情報と、実際のASTである Node が渡される
  • 出力:違反があった場合、 fail という変数に以下のフィールドをもつ構造体を入れる
  • msg: 違反内容のメッセージ(文字列)
  • pos: ファイル内の位置を示す整数値
  • sev: 深刻度。INFO, WARNING, もしくは ERROR

そのため基本フォーマットは以下のようになります

package goast

fail[res] {
    # ここにルールを記述

    res := {
        "msg": "エラーメッセージ",
        "pos": エラーの位置.~~Pos,
        "sev": "ERROR",
    }
}

次にルールを書いていきたいですが、その前にいまのコードの構造がどうなっているのかASTを出力して確認したほうが良いです。

ASTの出力

goast dump main.go >> ast.json

出力されたast.jsonから、該当のfor文にあたるものを探します

{
  "Path": "main.go",
  "FileName": "main.go",
  "DirName": ".",
  "Node": {
    "For": 171,
    "Key": {
      "NamePos": 175,
      "Name": "_",
      "Obj": null
    },
    "Value": {
      "NamePos": 178,
      "Name": "v",
      "Obj": null
    },
    "TokPos": 180,
    "Tok": 47,
    "Range": 183,
    "X": {
      "NamePos": 189,
      "Name": "users",
      "Obj": null
    },
    "Body": {
      "Lbrace": 195,
      "List": [
        {
          "Lhs": [
            {
              "NamePos": 199,
              "Name": "names",
              "Obj": null
            }
          ],
          "TokPos": 205,
          "Tok": 42,
          "Rhs": [
            {
              "Fun": {
                "NamePos": 207,
                "Name": "append",
                "Obj": null
              },
              "Lparen": 213,
              "Args": [
                {
                  "NamePos": 214,
                  "Name": "names",
                  "Obj": null
                },
                {
                  "OpPos": 221,
                  "Op": 17,
                  "X": {
                    "X": {
                      "NamePos": 222,
                      "Name": "v",
                      "Obj": null
                    },
                    "Sel": {
                      "NamePos": 224,
                      "Name": "name",
                      "Obj": null
                    }
                  }
                }
              ],
              "Ellipsis": 0,
              "Rparen": 228
            }
          ]
        }
      ],
      "Rbrace": 231
    }
  },
  "Kind": "RangeStmt"
}

"Op": 17が、演算子(Op)「&」(17)を表しています。

Regoファイルの作成

最後にRegoファイルの書き方です。

基本的にはASTのjsonを探索していって、すべての条件がマッチしたものだけが最後のresにたどり着いてエラー文を吐きます。

たとえば以下のルールでは、for rangeのvalueの使用自体を禁止できます。

# do_not_use_for_range_value.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null

    res := {
        "msg": "do not use for range value",
        "pos": input.Node.Value.NamePos,
        "sev": "ERROR",
    }
}
$ goast eval -p .goast/do_not_use_for_range_value.rego main.go
[main.go:13] - do not use for range value

	for _, v := range users {
	       ~~~~~~~~~~~~~~~~~~

[main.go:16] - do not use for range value

	for _, v := range names {
	       ~~~~~~~~~~~~~~~~~~


	Detected 2 violations

複雑な探索をしたいときは、walk関数が便利です。
walk関数のドキュメントのスクリーンショット
pathはあるkeyまでのkeyの配列、valueはそのkey以下のjsonです。

例えば、以下のように書くと


fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [path, value] := walk(input)
    value.Op == 17
    
}

pathとvalueの値はそれぞれ

path:  ["Node","Body","List",0,"Rhs",0,"Args",1]
value: {"Op":17,"OpPos":221,"X":{"Sel":{"Name":"name","NamePos":224,"Obj":null},"X":{"Name":"v","NamePos":222,"Obj":null}}}

となります。

※ちなみにdebug時にpathやvalueを確認したい場合は、json.marshal関数で文字列に変換すればresのmsgに含ませたりすることで書き出せます。

これを利用すれば、for rangeの中で&vの使用を禁止するルールがかけます。

# do_not_use_for_range_value_memory_address.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [path, value] := walk(input)
    value.Op == 17 # operator: &
    [pathDetail, valueDetail] := walk(value)
    valueDetail.Name == input.Node.Value.Name

    res := {
        "msg": "do not use for range value memory address",
        "pos": value.OpPos,
        "sev": "ERROR",
    }
}

append関数の中での&vの使用を禁止したければ、appendがあるリストと同階層から探索すればよさそうなので、以下のようになります。

# do_not_append_for_range_value_memory_address.rego
package goast

fail[res] {
    input.Kind == "RangeStmt"
    input.Node.Value != null
    [appendPath, appendValue] := walk(input)
    appendValue[_].Fun.Name == "append" # search for append func only
    [path, value] := walk(appendValue)
    value.Op == 17 # operator: &
    [pathDetail, valueDetail] := walk(value)
    valueDetail.Name == input.Node.Value.Name

    res := {
        "msg": "do not append for range value memory address",
        "pos": value.OpPos,
        "sev": "ERROR",
    }
}

おわりに

Resilireでは仲間を募集しています。

サーバーサイドだけでなく、フロントエンドやSREの採用も積極的に行っています。話を聴いてみたい!だけでも良いので、ご興味ある方はぜひご連絡ください!

https://recruit.resilire.jp/for-engineers

Discussion