jsonv2.go (9794B)
1 // Copyright 2021 The Go Authors. All rights reserved. 2 3 // This file is a modified copy of Go's encoding/json/v2/field.go 4 5 package sa5008 6 7 import ( 8 "fmt" 9 "go/ast" 10 "go/types" 11 "io" 12 "strconv" 13 "strings" 14 "unicode" 15 "unicode/utf8" 16 17 "honnef.co/go/tools/analysis/report" 18 "honnef.co/go/tools/go/types/typeutil" 19 20 "golang.org/x/tools/go/analysis" 21 ) 22 23 func validateJSONTag(pass *analysis.Pass, field *ast.Field, tag string) { 24 hasTag := tag != "" 25 tagOrig := tag 26 27 // Check whether this field is explicitly ignored. 28 if tag == "-" { 29 return 30 } 31 32 // Check whether this field is unexported and not embedded, 33 // which Go reflection cannot mutate for the sake of serialization. 34 // 35 // An embedded field of an unexported type is still capable of 36 // forwarding exported fields, which may be JSON serialized. 37 // This technically operates on the edge of what is permissible by 38 // the Go language, but the most recent decision is to permit this. 39 // 40 // See https://go.dev/issue/24153 and https://go.dev/issue/32772. 41 anonymous := len(field.Names) == 0 42 if !anonymous && !field.Names[0].IsExported() { 43 // Tag options specified on an unexported field suggests user error. 44 if hasTag { 45 report.Report(pass, field.Tag, 46 fmt.Sprintf("unexported struct field cannot have non-ignored `json:%q` tag", tag)) 47 } 48 return 49 } 50 51 if len(tag) > 0 && !strings.HasPrefix(tag, ",") { 52 // For better compatibility with v1, accept almost any unescaped name. 53 n := len(tag) - len(strings.TrimLeftFunc(tag, func(r rune) bool { 54 return !strings.ContainsRune(",\\'\"`", r) // reserve comma, backslash, and quotes 55 })) 56 name := tag[:n] 57 58 // If the next character is not a comma, then the name is either 59 // malformed (if n > 0) or a single-quoted name. 60 // In either case, call consumeTagOption to handle it further. 61 var err error 62 if !strings.HasPrefix(tag[n:], ",") && len(name) != len(tag) { 63 name, n, err = consumeTagOption(tag) 64 if err != nil { 65 report.Report(pass, field.Tag, fmt.Sprintf("malformed `json` tag: %v", err)) 66 } 67 } 68 if !utf8.ValidString(name) { 69 report.Report(pass, field.Tag, 70 fmt.Sprintf("invalid UTF-8 in JSON object name %q", name)) 71 name = string([]rune(name)) // replace invalid UTF-8 with utf8.RuneError 72 } 73 if name == "-" && tag[0] == '-' { 74 // TODO(dh): offer automatic fix 75 report.Report(pass, field.Tag, 76 fmt.Sprintf("should encoding/json ignore this field or name it \"-\"? Either use `json:\"-\"` to ignore the field or use `json:\"'-'%s` to specify %q as the name", 77 strings.TrimPrefix(strconv.Quote(tagOrig), `"-`), name)) 78 } 79 tag = tag[n:] 80 } 81 82 // Handle any additional tag options (if any). 83 var wasFormat bool 84 seenOpts := make(map[string]bool) 85 for len(tag) > 0 { 86 // Consume comma delimiter. 87 if tag[0] != ',' { 88 report.Report(pass, field.Tag, 89 fmt.Sprintf("malformed `json` tag: invalid character %q before next option (expecting ',')", 90 tag[0])) 91 } else { 92 tag = tag[len(","):] 93 if len(tag) == 0 { 94 report.Report(pass, field.Tag, "malformed `json` tag: invalid trailing ',' character") 95 break 96 } 97 } 98 99 // Consume and process the tag option. 100 opt, n, err := consumeTagOption(tag) 101 if err != nil { 102 report.Report(pass, field.Tag, fmt.Sprintf("malformed `json` tag: %v", err)) 103 } 104 rawOpt := tag[:n] 105 tag = tag[n:] 106 switch { 107 case wasFormat: 108 report.Report(pass, field.Tag, "`format` tag option was not specified last") 109 case strings.HasPrefix(rawOpt, "'") && strings.TrimFunc(opt, isLetterOrDigit) == "": 110 // TODO(dh): offer automatic fix 111 report.Report(pass, field.Tag, 112 fmt.Sprintf("unnecessarily quoted appearance of `%s` tag option; specify `%s` instead", rawOpt, opt)) 113 } 114 switch opt { 115 case "case": 116 if !strings.HasPrefix(tag, ":") { 117 // TODO(dh): offer automatic fix 118 report.Report(pass, field.Tag, 119 "missing value for `case` tag option; specify `case:ignore` or `case:strict` instead") 120 break 121 } 122 tag = tag[len(":"):] 123 opt, n, err := consumeTagOption(tag) 124 if err != nil { 125 report.Report(pass, field.Tag, 126 fmt.Sprintf("malformed value for `case` tag option: %v", err)) 127 break 128 } 129 rawOpt := tag[:n] 130 tag = tag[n:] 131 if strings.HasPrefix(rawOpt, "'") { 132 // TODO(dh): offer automatic fix 133 report.Report(pass, field.Tag, 134 fmt.Sprintf("unnecessarily quoted appearance of `case:%s` tag option; specify `case:%s` instead", 135 rawOpt, opt)) 136 } 137 switch opt { 138 case "ignore": 139 case "strict": 140 default: 141 report.Report(pass, field.Tag, 142 fmt.Sprintf("invalid appearance of unknown `case:%s` tag value", rawOpt)) 143 } 144 case "embed", "inline": 145 case "unknown": 146 case "omitzero": 147 case "omitempty": 148 case "string": 149 const msg = "invalid appearance of `string` tag option; it is only intended for fields of numeric types or pointers to those" 150 tset := typeutil.NewTypeSet(pass.TypesInfo.TypeOf(field.Type)) 151 if len(tset.Terms) == 0 { 152 // TODO(dh): improve message, call out the use of type parameters 153 report.Report(pass, field.Tag, msg) 154 continue 155 } 156 for _, term := range tset.Terms { 157 T := typeutil.Dereference(term.Type().Underlying()) 158 for _, term2 := range typeutil.NewTypeSet(T).Terms { 159 basic, ok := term2.Type().Underlying().(*types.Basic) 160 // We accept bools and strings because v1 of encoding/json 161 // supports those. We don't mention that in the message, 162 // however, because their support is accidental, and v2 163 // doesn't support it. 164 if !ok || (basic.Info()&(types.IsBoolean|types.IsInteger|types.IsFloat|types.IsString)) == 0 { 165 // TODO(dh): improve message, show how we arrived at the type 166 report.Report(pass, field.Tag, msg) 167 } 168 } 169 } 170 case "format": 171 if !strings.HasPrefix(tag, ":") { 172 report.Report(pass, field.Tag, "missing value for `format` tag option") 173 break 174 } 175 tag = tag[len(":"):] 176 _, n, err := consumeTagOption(tag) 177 if err != nil { 178 report.Report(pass, field.Tag, 179 fmt.Sprintf("malformed value for `format` tag option: %v", err)) 180 break 181 } 182 tag = tag[n:] 183 wasFormat = true 184 default: 185 // Reject keys that resemble one of the supported options. 186 // This catches invalid mutants such as "omitEmpty" or "omit_empty". 187 normOpt := strings.ReplaceAll(strings.ToLower(opt), "_", "") 188 switch normOpt { 189 case "case", "inline", "unknown", "omitzero", "omitempty", "string", "format": 190 report.Report(pass, field.Tag, 191 fmt.Sprintf("invalid appearance of `%s` tag option; specify `%s` instead", 192 opt, normOpt)) 193 default: 194 report.Report(pass, field.Tag, 195 fmt.Sprintf("invalid appearance of unknown `%s` tag option", opt)) 196 } 197 } 198 199 // Reject duplicates. 200 if seenOpts[opt] { 201 report.Report(pass, field.Tag, 202 fmt.Sprintf("duplicate appearance of `%s` tag option", rawOpt)) 203 } 204 seenOpts[opt] = true 205 } 206 207 if seenOpts["inline"] && seenOpts["unknown"] { 208 report.Report(pass, field.Tag, 209 "field cannot have both `inline` and `unknown` specified") 210 } 211 212 // TODO(dh): implement more restrictions for types of inlined and unknown 213 // fields, including recursive restrictions: 214 // 215 // - Go struct field %s cannot have any options other than `inline` or `unknown` specified 216 // - inlined Go struct field %s of type %s with `unknown` tag must be a Go map of string key or a jsontext.Value 217 // - inlined Go struct field %s is not exported 218 // - inlined map field %s of type %s must have a string key that does not implement marshal or unmarshal methods 219 // - inlined Go struct field %s of type %s must be a Go struct, Go map of string key, or jsontext.Value 220 } 221 222 // consumeTagOption consumes the next option, 223 // which is either a Go identifier or a single-quoted string. 224 // If the next option is invalid, it returns all of in until the next comma, 225 // and reports an error. 226 func consumeTagOption(in string) (string, int, error) { 227 // For legacy compatibility with v1, assume options are comma-separated. 228 i := strings.IndexByte(in, ',') 229 if i < 0 { 230 i = len(in) 231 } 232 233 switch r, _ := utf8.DecodeRuneInString(in); { 234 // Option as a Go identifier. 235 case r == '_' || unicode.IsLetter(r): 236 n := len(in) - len(strings.TrimLeftFunc(in, isLetterOrDigit)) 237 return in[:n], n, nil 238 // Option as a single-quoted string. 239 case r == '\'': 240 // The grammar is nearly identical to a double-quoted Go string literal, 241 // but uses single quotes as the terminators. The reason for a custom 242 // grammar is because both backtick and double quotes cannot be used 243 // verbatim in a struct tag. 244 // 245 // Convert a single-quoted string to a double-quote string and rely on 246 // strconv.Unquote to handle the rest. 247 var inEscape bool 248 b := []byte{'"'} 249 n := len(`'`) 250 for len(in) > n { 251 r, rn := utf8.DecodeRuneInString(in[n:]) 252 switch { 253 case inEscape: 254 if r == '\'' { 255 b = b[:len(b)-1] // remove escape character: `\'` => `'` 256 } 257 inEscape = false 258 case r == '\\': 259 inEscape = true 260 case r == '"': 261 b = append(b, '\\') // insert escape character: `"` => `\"` 262 case r == '\'': 263 b = append(b, '"') 264 n += len(`'`) 265 out, err := strconv.Unquote(string(b)) 266 if err != nil { 267 return in[:i], i, fmt.Errorf("invalid single-quoted string: %s", in[:n]) 268 } 269 return out, n, nil 270 } 271 b = append(b, in[n:][:rn]...) 272 n += rn 273 } 274 if n > 10 { 275 n = 10 // limit the amount of context printed in the error 276 } 277 //lint:ignore ST1005 The ellipsis denotes truncated text 278 return in[:i], i, fmt.Errorf("single-quoted string not terminated: %s...", in[:n]) 279 case len(in) == 0: 280 return in[:i], i, io.ErrUnexpectedEOF 281 default: 282 return in[:i], i, fmt.Errorf("invalid character %q at start of option (expecting Unicode letter or single quote)", r) 283 } 284 } 285 286 func isLetterOrDigit(r rune) bool { 287 return r == '_' || unicode.IsLetter(r) || unicode.IsNumber(r) 288 }