cmd/jsontags: add static analyzer for incompatible json struct tags (#17670)

This migrates an internal tool to open source
so that we can run it on the tailscale.com module as well.

This PR does not yet set up a CI to run this analyzer.

Updates tailscale/corp#791

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
This commit is contained in:
Joe Tsai
2025-10-28 14:53:59 -07:00
committed by GitHub
parent 478342a642
commit 9ac8105fda
3 changed files with 411 additions and 0 deletions

201
cmd/jsontags/analyzer.go Normal file
View File

@@ -0,0 +1,201 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package jsontags checks for incompatible usage of JSON struct tags.
package jsontags
import (
"go/ast"
"go/types"
"reflect"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)
var Analyzer = &analysis.Analyzer{
Name: "jsonvet",
Doc: "check for incompatible usages of JSON struct tags",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
func run(pass *analysis.Pass) (any, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
// TODO: Report byte arrays fields without an explicit `format` tag option.
inspect.Preorder([]ast.Node{(*ast.StructType)(nil)}, func(n ast.Node) {
structType, ok := pass.TypesInfo.Types[n.(*ast.StructType)].Type.(*types.Struct)
if !ok {
return // type information may be incomplete
}
for i := range structType.NumFields() {
fieldVar := structType.Field(i)
tag := reflect.StructTag(structType.Tag(i)).Get("json")
if tag == "" {
continue
}
var seenName, hasFormat bool
for opt := range strings.SplitSeq(tag, ",") {
if !seenName {
seenName = true
continue
}
switch opt {
case "omitempty":
// For bools, ints, uints, floats, strings, and interfaces,
// it is always safe to migrate from `omitempty` to `omitzero`
// so long as the type does not have an IsZero method or
// the IsZero method is identical to reflect.Value.IsZero.
//
// For pointers, it is only safe to migrate from `omitempty` to `omitzero`
// so long as the type does not have an IsZero method, regardless of
// whether the IsZero method is identical to reflect.Value.IsZero.
//
// For pointers, `omitempty` behaves identically on both v1 and v2
// so long as the type does not implement a Marshal method that
// might serialize as an empty JSON value (i.e., null, "", [], or {}).
hasIsZero := hasIsZeroMethod(fieldVar.Type()) && !hasPureIsZeroMethod(fieldVar.Type())
underType := fieldVar.Type().Underlying()
basic, isBasic := underType.(*types.Basic)
array, isArrayKind := underType.(*types.Array)
_, isMapKind := underType.(*types.Map)
_, isSliceKind := underType.(*types.Slice)
_, isPointerKind := underType.(*types.Pointer)
_, isInterfaceKind := underType.(*types.Interface)
supportedInV1 := isNumericKind(underType) ||
isBasic && basic.Kind() == types.Bool ||
isBasic && basic.Kind() == types.String ||
isArrayKind && array.Len() == 0 ||
isMapKind || isSliceKind || isPointerKind || isInterfaceKind
notSupportedInV2 := isNumericKind(underType) ||
isBasic && basic.Kind() == types.Bool
switch {
case isMapKind, isSliceKind:
// This operates the same under both v1 and v2 so long as
// the map or slice type does not implement Marshal
// that could emit an empty JSON value for cases
// other than when the map or slice are empty.
// This is very rare.
case isString(fieldVar.Type()):
// This operates the same under both v1 and v2.
// These are safe to migrate to `omitzero`,
// but doing so is probably unnecessary churn.
// Note that this is only for a unnamed string type.
case !supportedInV1:
// This never worked in v1. Switching to `omitzero`
// may lead to unexpected behavior changes.
report(pass, structType, fieldVar, OmitEmptyUnsupportedInV1)
case notSupportedInV2:
// This does not work in v2. Switching to `omitzero`
// may lead to unexpected behavior changes.
report(pass, structType, fieldVar, OmitEmptyUnsupportedInV2)
case !hasIsZero:
// These are safe to migrate to `omitzero` such that
// it behaves identically under v1 and v2.
report(pass, structType, fieldVar, OmitEmptyShouldBeOmitZero)
case isPointerKind:
// This operates the same under both v1 and v2 so long as
// the pointer type does not implement Marshal that
// could emit an empty JSON value.
// For example, time.Time is safe since the zero value
// never marshals as an empty JSON string.
default:
// This is a non-pointer type with an IsZero method.
// If IsZero is not identical to reflect.Value.IsZero,
// omission may behave slightly differently when using
// `omitzero` instead of `omitempty`.
// Thus the finding uses the word "should".
report(pass, structType, fieldVar, OmitEmptyShouldBeOmitZeroButHasIsZero)
}
case "string":
if !isNumericKind(fieldVar.Type()) {
report(pass, structType, fieldVar, StringOnNonNumericKind)
}
default:
key, _, ok := strings.Cut(opt, ":")
hasFormat = key == "format" && ok
}
}
if !hasFormat && isTimeDuration(mayPointerElem(fieldVar.Type())) {
report(pass, structType, fieldVar, FormatMissingOnTimeDuration)
}
}
})
return nil, nil
}
// hasIsZeroMethod reports whether t has an IsZero method.
func hasIsZeroMethod(t types.Type) bool {
for method := range types.NewMethodSet(t).Methods() {
if fn, ok := method.Type().(*types.Signature); ok && method.Obj().Name() == "IsZero" {
if fn.Params().Len() == 0 && fn.Results().Len() == 1 && isBool(fn.Results().At(0).Type()) {
return true
}
}
}
return false
}
// isBool reports whether t is a bool type.
func isBool(t types.Type) bool {
basic, ok := t.(*types.Basic)
return ok && basic.Kind() == types.Bool
}
// isString reports whether t is a string type.
func isString(t types.Type) bool {
basic, ok := t.(*types.Basic)
return ok && basic.Kind() == types.String
}
// isTimeDuration reports whether t is a time.Duration type.
func isTimeDuration(t types.Type) bool {
return isNamed(t, "time", "Duration")
}
// mayPointerElem returns the pointed-at type if t is a pointer,
// otherwise it returns t as-is.
func mayPointerElem(t types.Type) types.Type {
if pointer, ok := t.(*types.Pointer); ok {
return pointer.Elem()
}
return t
}
// isNamed reports t is a named typed of the given path and name.
func isNamed(t types.Type, path, name string) bool {
gotPath, gotName := typeName(t)
return gotPath == path && gotName == name
}
// typeName reports the pkgPath and name of the type.
// It recursively follows type aliases to get the underlying named type.
func typeName(t types.Type) (pkgPath, name string) {
if named, ok := types.Unalias(t).(*types.Named); ok {
obj := named.Obj()
if pkg := obj.Pkg(); pkg != nil {
return pkg.Path(), obj.Name()
}
return "", obj.Name()
}
return "", ""
}
// isNumericKind reports whether t is a numeric kind.
func isNumericKind(t types.Type) bool {
if basic, ok := t.Underlying().(*types.Basic); ok {
switch basic.Kind() {
case types.Int, types.Int8, types.Int16, types.Int32, types.Int64:
case types.Uint, types.Uint8, types.Uint16, types.Uint32, types.Uint64, types.Uintptr:
case types.Float32, types.Float64:
default:
return false
}
return true
}
return false
}

75
cmd/jsontags/iszero.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package jsontags
import (
"go/types"
"reflect"
"tailscale.com/util/set"
)
var _ = reflect.Value.IsZero // refer for hot-linking purposes
var pureIsZeroMethods map[string]set.Set[string]
// hasPureIsZeroMethod reports whether the IsZero method is truly
// identical to [reflect.Value.IsZero].
func hasPureIsZeroMethod(t types.Type) bool {
// TODO: Detect this automatically by checking the method AST?
path, name := typeName(t)
return pureIsZeroMethods[path].Contains(name)
}
// PureIsZeroMethodsInTailscaleModule is a list of known IsZero methods
// in the "tailscale.com" module that are pure.
var PureIsZeroMethodsInTailscaleModule = map[string]set.Set[string]{
"tailscale.com/net/packet": set.Of(
"TailscaleRejectReason",
),
"tailscale.com/tailcfg": set.Of(
"UserID",
"LoginID",
"NodeID",
"StableNodeID",
),
"tailscale.com/tka": set.Of(
"AUMHash",
),
"tailscale.com/types/geo": set.Of(
"Point",
),
"tailscale.com/tstime/mono": set.Of(
"Time",
),
"tailscale.com/types/key": set.Of(
"NLPrivate",
"NLPublic",
"DERPMesh",
"MachinePrivate",
"MachinePublic",
"ControlPrivate",
"DiscoPrivate",
"DiscoPublic",
"DiscoShared",
"HardwareAttestationPublic",
"ChallengePublic",
"NodePrivate",
"NodePublic",
),
"tailscale.com/types/netlogtype": set.Of(
"Connection",
"Counts",
),
}
// RegisterPureIsZeroMethods specifies a list of pure IsZero methods
// where it is identical to calling [reflect.Value.IsZero] on the receiver.
// This is not strictly necessary, but allows for more accurate
// detection of improper use of `json` tags.
//
// This must be called at init and the input must not be mutated.
func RegisterPureIsZeroMethods(methods map[string]set.Set[string]) {
pureIsZeroMethods = methods
}

135
cmd/jsontags/report.go Normal file
View File

@@ -0,0 +1,135 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package jsontags
import (
"fmt"
"go/types"
"os"
"strings"
_ "embed"
"golang.org/x/tools/go/analysis"
"tailscale.com/util/set"
)
var jsontagsAllowlist map[ReportKind]set.Set[string]
// ParseAllowlist parses an allowlist of reports to ignore,
// which is a newline-delimited list of tuples separated by a tab,
// where each tuple is a [ReportKind] and a fully-qualified field name.
//
// For example:
//
// OmitEmptyUnsupportedInV1 tailscale.com/path/to/package.StructType.FieldName
// OmitEmptyUnsupportedInV1 tailscale.com/path/to/package.*.FieldName
//
// The struct type name may be "*" for anonymous struct types such
// as those declared within a function or as a type literal in a variable.
func ParseAllowlist(b []byte) map[ReportKind]set.Set[string] {
var allowlist map[ReportKind]set.Set[string]
for line := range strings.SplitSeq(string(b), "\n") {
kind, field, _ := strings.Cut(strings.TrimSpace(line), "\t")
if allowlist == nil {
allowlist = make(map[ReportKind]set.Set[string])
}
fields := allowlist[ReportKind(kind)]
if fields == nil {
fields = make(set.Set[string])
}
fields.Add(field)
allowlist[ReportKind(kind)] = fields
}
return allowlist
}
// RegisterAllowlist registers an allowlist of reports to ignore,
// which is represented by a set of fully-qualified field names
// for each [ReportKind].
//
// For example:
//
// {
// "OmitEmptyUnsupportedInV1": set.Of(
// "tailscale.com/path/to/package.StructType.FieldName",
// "tailscale.com/path/to/package.*.FieldName",
// ),
// }
//
// The struct type name may be "*" for anonymous struct types such
// as those declared within a function or as a type literal in a variable.
//
// This must be called at init and the input must not be mutated.
func RegisterAllowlist(allowlist map[ReportKind]set.Set[string]) {
jsontagsAllowlist = allowlist
}
type ReportKind string
const (
OmitEmptyUnsupportedInV1 ReportKind = "OmitEmptyUnsupportedInV1"
OmitEmptyUnsupportedInV2 ReportKind = "OmitEmptyUnsupportedInV2"
OmitEmptyShouldBeOmitZero ReportKind = "OmitEmptyShouldBeOmitZero"
OmitEmptyShouldBeOmitZeroButHasIsZero ReportKind = "OmitEmptyShouldBeOmitZeroButHasIsZero"
StringOnNonNumericKind ReportKind = "StringOnNonNumericKind"
FormatMissingOnTimeDuration ReportKind = "FormatMissingOnTimeDuration"
)
func (k ReportKind) message() string {
switch k {
case OmitEmptyUnsupportedInV1:
return "uses `omitempty` on an unspported type in json/v1; should probably use `omitzero` instead"
case OmitEmptyUnsupportedInV2:
return "uses `omitempty` on an unspported type in json/v2; should probably use `omitzero` instead"
case OmitEmptyShouldBeOmitZero:
return "should use `omitzero` instead of `omitempty`"
case OmitEmptyShouldBeOmitZeroButHasIsZero:
return "should probably use `omitzero` instead of `omitempty`"
case StringOnNonNumericKind:
return "must not use `string` on non-numeric types"
case FormatMissingOnTimeDuration:
return "must use an explicit `format` tag (e.g., `format:nano`) on a time.Duration type; see https://go.dev/issue/71631"
default:
return string(k)
}
}
func report(pass *analysis.Pass, structType *types.Struct, fieldVar *types.Var, k ReportKind) {
// Lookup the full name of the struct type.
var fullName string
for _, name := range pass.Pkg.Scope().Names() {
if obj := pass.Pkg.Scope().Lookup(name); obj != nil {
if named, ok := obj.(*types.TypeName); ok {
if types.Identical(named.Type().Underlying(), structType) {
fullName = fmt.Sprintf("%v.%v.%v", named.Pkg().Path(), named.Name(), fieldVar.Name())
break
}
}
}
}
if fullName == "" {
// Full name could not be found since this is probably an anonymous type
// or locally declared within a function scope.
// Use just the package path and field name instead.
// This is imprecise, but better than nothing.
fullName = fmt.Sprintf("%s.*.%s", fieldVar.Pkg().Path(), fieldVar.Name())
}
if jsontagsAllowlist[k].Contains(fullName) {
return
}
const appendAllowlist = ""
if appendAllowlist != "" {
if f, err := os.OpenFile(appendAllowlist, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664); err == nil {
fmt.Fprintf(f, "%v\t%v\n", k, fullName)
f.Close()
}
}
pass.Report(analysis.Diagnostic{
Pos: fieldVar.Pos(),
Message: fmt.Sprintf("field %q %s", fieldVar.Name(), k.message()),
})
}