refactor: move go-chi/binding into Gitea (#39528)

The `gitea.com/go-chi/binding` package only exists for Gitea, so it
moves into `modules/web/binding` to fix its bugs directly. Split out of
https://github.com/go-gitea/gitea/pull/39504.

- GET and HEAD always bind the query
- JSON `null` slice elements and nested `TrimSpace` fields bind
correctly
- Integer fields reject out-of-range values instead of wrapping
- An empty JSON body binds nothing and an unknown binding rule is an
error

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
authored and GitHub committed 2026-10-02 15:37:41 +02:00
1 parent 2f01ec38bd
commit e6ffbea888
17 files changed
+748 -72

No files matched your search

-5
View File
@@ -14,11 +14,6 @@
"path": "filippo.io/edwards25519/LICENSE",
"licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
},
{
"name": "gitea.com/go-chi/binding",
"path": "gitea.com/go-chi/binding/LICENSE",
"licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that YLine truncated
},
{
"name": "gitea.com/go-chi/cache",
"path": "gitea.com/go-chi/cache/LICENSE",
-1
View File
@@ -6,7 +6,6 @@ toolchain go1.27.1
require (
connectrpc.com/connect v1.21.0
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981
gitea.com/go-chi/cache v0.2.1
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
-2
View File
@@ -8,8 +8,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981 h1:LmdlwGbzgZFZA3bK3R1q8QrNabaSz3KpnOJBLmzhN6E=
gitea.com/go-chi/binding v0.0.0-20260819122636-082915a69981/go.mod h1:q1SSPpkC9A0gfNnoqqZ3My6kEHIpKN6QsTI+Zx73B/o=
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=
+1 -1
View File
@@ -53,7 +53,7 @@ type EditUserOption struct {
// Whether the user must change password on next login
MustChangePassword *bool `json:"must_change_password"`
// The user's personal website URL
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
// The user's location or address
Location *string `json:"location" binding:"MaxSize(50)"`
// The user's personal description or bio
+1 -2
View File
@@ -5,8 +5,7 @@ package structs
import (
"gitea.dev/modules/translation/i18n"
"gitea.com/go-chi/binding" //nolint:depguard // avoid cycle import
"gitea.dev/modules/web/binding"
)
// ValidateContext is a special context for form validation middleware
+2 -2
View File
@@ -63,7 +63,7 @@ type CreateTeamOption struct {
// Whether the team can create repositories in the organization
CanCreateOrgRepo bool `json:"can_create_org_repo"`
// Team visibility within the organization. Defaults to "private".
Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
Visibility TeamVisibility `json:"visibility" binding:"In(public,limited,private)"`
}
// EditTeamOption options for editing a team
@@ -85,5 +85,5 @@ type EditTeamOption struct {
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
// Team visibility within the organization. When omitted, visibility is
// left unchanged.
Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
Visibility *TeamVisibility `json:"visibility" binding:"In(public,limited,private)"`
}
+1 -1
View File
@@ -173,7 +173,7 @@ type CreateRepoOption struct {
type EditRepoOption struct {
// name of the repository
// unique: true
Name *string `json:"name,omitempty" binding:"OmitEmpty;AlphaDashDot;MaxSize(100);"`
Name *string `json:"name,omitempty" binding:"AlphaDashDot;MaxSize(100)"`
// a short description of the repository.
Description *string `json:"description,omitempty" binding:"MaxSize(2048)"`
// a URL with more information about the repository.
+1 -1
View File
@@ -89,7 +89,7 @@ type UserSettings struct {
// swagger:model
type UserSettingsOptions struct {
FullName *string `json:"full_name" binding:"MaxSize(100)"`
Website *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
Website *string `json:"website" binding:"ValidUrl;MaxSize(255)"`
Description *string `json:"description" binding:"MaxSize(255)"`
Location *string `json:"location" binding:"MaxSize(50)"`
Language *string `json:"language"`
+8 -26
View File
@@ -5,7 +5,6 @@ package validation
import (
"context"
"io"
"reflect"
"regexp"
"strings"
@@ -14,14 +13,13 @@ import (
"gitea.dev/modules/auth"
"gitea.dev/modules/git"
"gitea.dev/modules/glob"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
"gitea.dev/modules/web/binding"
)
const (
ErrCustomMessage = "CustomMessage"
ErrEmail = "EmailError"
ErrURL = "UrlError"
ErrGitRefName = "GitRefNameError"
ErrGlobPattern = "GlobPattern"
ErrRegexPattern = "RegexPattern"
@@ -30,35 +28,19 @@ const (
ErrInvalidBadgeSlug = "InvalidBadgeSlug"
)
type jsonProvider struct{}
func (j jsonProvider) Marshal(v any) ([]byte, error) { return json.Marshal(v) }
func (j jsonProvider) Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
func (j jsonProvider) NewDecoder(reader io.Reader) binding.JSONDecoder {
return json.NewDecoder(reader)
}
func (j jsonProvider) NewEncoder(writer io.Writer) binding.JSONEncoder {
return json.NewEncoder(writer)
}
func newFieldError(field reflect.StructField, cls, msg string) *BindingError {
return &BindingError{FieldNames: []string{field.Name}, Classification: cls, Message: msg}
}
func AddValidationError(errs BindingErrors, fieldName, errorMsg string) BindingErrors {
errs.Add([]string{fieldName}, ErrCustomMessage, errorMsg)
return errs
return append(errs, BindingError{FieldNames: []string{fieldName}, Classification: ErrCustomMessage, Message: errorMsg})
}
// AddBindingRules adds additional binding rules
func AddBindingRules(b *binding.Binder) {
b.ClearRules("Email")
b.AddRuleNonZero("Email", func(_ context.Context, f *binding.ValidationField) *binding.Error {
if !IsEmailAddressValid(f.ValueMustString()) {
return newFieldError(f.StructField, binding.ERR_EMAIL, "invalid email")
return newFieldError(f.StructField, ErrEmail, "invalid email")
}
return nil
})
@@ -71,13 +53,13 @@ func AddBindingRules(b *binding.Binder) {
})
b.AddRuleNonZero("ValidUrl", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !IsValidURL(f.ValueMustString()) {
return newFieldError(f.StructField, binding.ERR_URL, "Url")
return newFieldError(f.StructField, ErrURL, "Url")
}
return nil
})
b.AddRuleNonZero("ValidSiteUrl", func(ctx context.Context, f *binding.ValidationField) *binding.Error {
if !IsValidSiteURL(f.ValueMustString()) {
return newFieldError(f.StructField, binding.ERR_URL, "Url")
return newFieldError(f.StructField, ErrURL, "Url")
}
return nil
})
@@ -152,7 +134,7 @@ func validPort(p string) bool {
}
var Binder = sync.OnceValue(func() *binding.Binder {
b := binding.NewBinder().WithJSONProvider(jsonProvider{}).WithDefaultRules().WithNameMapper(util.ToSnakeCase)
b := binding.NewBinder()
AddBindingRules(b)
return b
})
+391
View File
@@ -0,0 +1,391 @@
// Copyright 2014 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package binding binds form, multipart and JSON request data to structs and validates them by their "binding" tags.
package binding
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
)
const (
errContentType = "ContentTypeError"
errDeserialization = "DeserializationError"
errTypeCast = "TypeCastError"
errRule = "RuleError"
ErrRequired = "RequiredError"
ErrAlphaDashDot = "AlphaDashDotError"
ErrMinSize = "MinSizeError"
ErrMaxSize = "MaxSizeError"
ErrRange = "RangeError"
ErrIn = "InError"
ErrInclude = "IncludeError"
)
const multipartMaxMemory = 10 * 1024 * 1024
type (
Errors []Error
Error struct {
FieldNames []string
Classification string
Message string
}
)
func (e Error) Error() string {
return e.Message
}
func (e *Errors) addDeserializationError(err error) {
*e = append(*e, Error{Classification: errDeserialization, Message: err.Error()})
}
func (e *Errors) addOptional(err *Error) {
if err != nil {
*e = append(*e, *err)
}
}
func newFieldError(field reflect.StructField, classification, message string) *Error {
return &Error{FieldNames: []string{field.Name}, Classification: classification, Message: message}
}
type ValidationField struct {
StructField reflect.StructField
reflectValue reflect.Value
ruleArgs []string
}
func (f *ValidationField) valueAsString() string {
return fmt.Sprint(reflect.Indirect(f.reflectValue).Interface())
}
func (f *ValidationField) ValueMustString() string {
value := reflect.Indirect(f.reflectValue)
if value.Kind() != reflect.String {
panic("field value must be a string")
}
return value.String()
}
func (f *ValidationField) valueSize() int {
value := reflect.Indirect(f.reflectValue)
switch value.Kind() {
case reflect.String:
return utf8.RuneCountInString(value.String())
case reflect.Slice:
return value.Len()
}
panic("unsupported type: " + value.Kind().String())
}
func (f *ValidationField) assignValue(newValue any) {
if f.reflectValue.Kind() == reflect.Pointer {
ptr := reflect.New(f.StructField.Type.Elem())
ptr.Elem().Set(reflect.ValueOf(newValue).Convert(ptr.Elem().Type()))
f.reflectValue.Set(ptr)
return
}
f.reflectValue.Set(reflect.ValueOf(newValue).Convert(f.reflectValue.Type()))
}
type RuleValidator func(ctx context.Context, field *ValidationField) *Error
type ruleValidatorItem struct {
forZeroValue bool
validatorFn RuleValidator
}
type Binder struct {
rules map[string]ruleValidatorItem
}
func NewBinder() *Binder {
binder := &Binder{rules: map[string]ruleValidatorItem{}}
binder.AddRuleNonZero("TrimSpace", func(_ context.Context, field *ValidationField) *Error {
stringType := reflect.TypeFor[string]()
value := reflect.Indirect(field.reflectValue)
if !value.CanConvert(stringType) {
return newFieldError(field.StructField, errTypeCast, "TrimSpace")
}
field.assignValue(strings.TrimSpace(value.Convert(stringType).String()))
return nil
})
binder.rules["Required"] = ruleValidatorItem{forZeroValue: true, validatorFn: func(_ context.Context, field *ValidationField) *Error {
// a pointer field is optional, so "Required" only applies once a value is provided
if field.reflectValue.Kind() == reflect.Pointer && field.reflectValue.IsNil() {
return nil
}
return newFieldError(field.StructField, ErrRequired, "Required")
}}
binder.AddRuleNonZero("AlphaDashDot", func(_ context.Context, field *ValidationField) *Error {
if nonAlphaDashDotPattern().MatchString(field.ValueMustString()) {
return newFieldError(field.StructField, ErrAlphaDashDot, "AlphaDashDot")
}
return nil
})
binder.AddRuleNonZero("MinSize", func(_ context.Context, field *ValidationField) *Error {
minSize, _ := strconv.Atoi(field.ruleArgs[0])
if field.valueSize() < minSize {
return newFieldError(field.StructField, ErrMinSize, "MinSize")
}
return nil
})
binder.AddRuleNonZero("MaxSize", func(_ context.Context, field *ValidationField) *Error {
maxSize, _ := strconv.Atoi(field.ruleArgs[0])
if field.valueSize() > maxSize {
return newFieldError(field.StructField, ErrMaxSize, "MaxSize")
}
return nil
})
binder.AddRuleNonZero("Range", func(_ context.Context, field *ValidationField) *Error {
value, _ := strconv.Atoi(field.valueAsString())
minValue, _ := strconv.Atoi(field.ruleArgs[0])
maxValue, _ := strconv.Atoi(field.ruleArgs[1])
if value < minValue || value > maxValue {
return newFieldError(field.StructField, ErrRange, "Range")
}
return nil
})
binder.AddRuleNonZero("In", func(_ context.Context, field *ValidationField) *Error {
if !slices.Contains(field.ruleArgs, field.valueAsString()) {
return newFieldError(field.StructField, ErrIn, "In")
}
return nil
})
binder.AddRuleNonZero("Include", func(_ context.Context, field *ValidationField) *Error {
if !strings.Contains(field.ValueMustString(), strings.Join(field.ruleArgs, ",")) {
return newFieldError(field.StructField, ErrInclude, "Include")
}
return nil
})
return binder
}
func (b *Binder) AddRuleNonZero(name string, ruleValidator RuleValidator) {
b.rules[name] = ruleValidatorItem{validatorFn: ruleValidator}
}
func (b *Binder) Bind(req *http.Request, obj any) Errors {
ensurePointer(obj)
contentType := req.Header.Get("Content-Type")
if req.Method == http.MethodGet || req.Method == http.MethodHead ||
(contentType == "" && req.Method != http.MethodPost && req.Method != http.MethodPut) {
return b.bindForm(req, obj)
}
switch {
case strings.Contains(contentType, "form-urlencoded"):
return b.bindForm(req, obj)
case strings.Contains(contentType, "multipart/form-data"):
return b.bindMultipartForm(req, obj)
case strings.Contains(contentType, "json"):
return b.bindJSON(req, obj)
}
return Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
}
func (b *Binder) bindForm(req *http.Request, formStruct any) (errs Errors) {
if err := req.ParseForm(); err != nil {
errs.addDeserializationError(err)
}
errs = mapForm(reflect.ValueOf(formStruct), req.Form, nil, errs)
return append(errs, b.Validate(req.Context(), formStruct)...)
}
func (b *Binder) bindMultipartForm(req *http.Request, formStruct any) (errs Errors) {
if err := req.ParseMultipartForm(multipartMaxMemory); err != nil {
errs.addDeserializationError(err)
}
if req.MultipartForm != nil {
errs = mapForm(reflect.ValueOf(formStruct), req.MultipartForm.Value, req.MultipartForm.File, errs)
}
return append(errs, b.Validate(req.Context(), formStruct)...)
}
func (b *Binder) bindJSON(req *http.Request, jsonStruct any) (errs Errors) {
err := json.NewDecoder(req.Body).Decode(jsonStruct)
if err != nil && !errors.Is(err, io.EOF) { // an empty body binds nothing
errs.addDeserializationError(err)
}
return append(errs, b.Validate(req.Context(), jsonStruct)...)
}
func (b *Binder) Validate(ctx context.Context, obj any) Errors {
ensurePointer(obj)
return b.validateStruct(ctx, nil, reflect.ValueOf(obj).Elem())
}
func (b *Binder) validateStruct(ctx context.Context, errs Errors, structValue reflect.Value) Errors {
structType := structValue.Type()
for i := range structType.NumField() {
field := structType.Field(i)
fieldValue := structValue.Field(i)
if field.Tag.Get("form") == "-" || !fieldValue.CanInterface() {
continue
}
if field.Type.Kind() == reflect.Struct ||
(field.Type.Kind() == reflect.Pointer && !fieldValue.IsNil() && field.Type.Elem().Kind() == reflect.Struct) {
errs = b.validateStruct(ctx, errs, reflect.Indirect(fieldValue))
continue
}
errs = b.validateField(ctx, errs, &ValidationField{StructField: field, reflectValue: fieldValue})
}
return errs
}
func (b *Binder) validateField(ctx context.Context, errs Errors, field *ValidationField) Errors {
if field.reflectValue.Kind() == reflect.Slice {
for i := range field.reflectValue.Len() {
if elem := reflect.Indirect(field.reflectValue.Index(i)); elem.Kind() == reflect.Struct {
errs = b.validateStruct(ctx, errs, elem)
}
}
}
for rule := range strings.SplitSeq(field.StructField.Tag.Get("binding"), ";") {
rule = strings.TrimSpace(rule)
if rule == "" {
continue
}
ruleName, ruleArgs, _ := strings.Cut(rule, "(")
field.ruleArgs = nil
if ruleArgs != "" {
field.ruleArgs = strings.Split(strings.TrimSuffix(ruleArgs, ")"), ",")
}
item, ok := b.rules[ruleName]
if !ok {
panic(fmt.Sprintf("Invalid binding rule: %q", ruleName))
}
value := reflect.Indirect(field.reflectValue)
if item.forZeroValue != (!value.IsValid() || value.IsZero()) {
continue
}
if err := item.validatorFn(ctx, field); err != nil {
return append(errs, *err)
}
}
return errs
}
var nonAlphaDashDotPattern = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`[^\w-.]`)
})
func mapForm(formStruct reflect.Value, form map[string][]string, formFiles map[string][]*multipart.FileHeader, errs Errors) Errors {
formStruct = reflect.Indirect(formStruct)
structType := formStruct.Type()
for fieldIdx := range structType.NumField() {
typeField := structType.Field(fieldIdx)
fieldValue := formStruct.Field(fieldIdx)
if typeField.Type.Kind() == reflect.Pointer && typeField.Anonymous {
fieldValue.Set(reflect.New(typeField.Type.Elem()))
errs = mapForm(fieldValue.Elem(), form, formFiles, errs)
if fieldValue.Elem().IsZero() {
fieldValue.SetZero()
}
} else if typeField.Type.Kind() == reflect.Struct {
errs = mapForm(fieldValue, form, formFiles, errs)
}
inputFieldName := typeField.Tag.Get("form")
if inputFieldName == "-" || !typeField.IsExported() {
continue
}
if inputFieldName == "" {
inputFieldName = util.ToSnakeCase(typeField.Name)
}
if inputValues, exists := form[inputFieldName]; exists {
if fieldValue.Kind() == reflect.Slice && len(inputValues) > 0 {
slice := reflect.MakeSlice(fieldValue.Type(), len(inputValues), len(inputValues))
for elemIdx, inputValue := range inputValues {
errs.addOptional(setWithProperType(typeField, inputValue, slice.Index(elemIdx)))
}
fieldValue.Set(slice)
} else {
errs.addOptional(setWithProperType(typeField, inputValues[0], fieldValue))
}
continue
}
inputFiles, exists := formFiles[inputFieldName]
if !exists {
continue
}
fileHeaderType := reflect.TypeFor[*multipart.FileHeader]()
if fieldValue.Kind() == reflect.Slice && len(inputFiles) > 0 && fieldValue.Type().Elem() == fileHeaderType {
fieldValue.Set(reflect.ValueOf(slices.Clone(inputFiles)))
} else if fieldValue.Type() == fileHeaderType {
fieldValue.Set(reflect.ValueOf(inputFiles[0]))
}
}
return errs
}
func setWithProperType(structField reflect.StructField, val string, fieldValue reflect.Value) *Error {
switch fieldValue.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
intVal, err := strconv.ParseInt(cmp.Or(val, "0"), 10, fieldValue.Type().Bits())
if err != nil {
return newFieldError(structField, errTypeCast, "Value could not be parsed as integer")
}
fieldValue.SetInt(intVal)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
uintVal, err := strconv.ParseUint(cmp.Or(val, "0"), 10, fieldValue.Type().Bits())
if err != nil {
return newFieldError(structField, errTypeCast, "Value could not be parsed as unsigned integer")
}
fieldValue.SetUint(uintVal)
case reflect.Bool:
if val == "on" {
fieldValue.SetBool(true)
break
}
boolVal, err := strconv.ParseBool(cmp.Or(val, "false"))
if err != nil {
return newFieldError(structField, errTypeCast, "Value could not be parsed as boolean")
}
fieldValue.SetBool(boolVal)
case reflect.String:
fieldValue.SetString(val)
case reflect.Pointer:
newValue := reflect.New(fieldValue.Type().Elem())
if err := setWithProperType(structField, val, newValue.Elem()); err != nil {
return err
}
fieldValue.Set(newValue)
default:
return newFieldError(structField, errDeserialization, "unsupported type: "+fieldValue.Kind().String())
}
return nil
}
func ensurePointer(obj any) {
if reflect.TypeOf(obj).Kind() != reflect.Pointer {
panic("Pointers are only accepted as binding models")
}
}
+323
View File
@@ -0,0 +1,323 @@
// Copyright 2014 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package binding
import (
"bytes"
"cmp"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type (
Post struct {
Title string `form:"title" json:"title" binding:"Required"`
Content string `form:"content" json:"content"`
}
Person struct {
Name string `form:"name" json:"name" binding:"Required"`
Email string `form:"email" json:"email"`
}
BlogPost struct {
Post
ID int `json:"id" binding:"Required"`
Ignored string `form:"-" json:"-"`
Ratings []int `form:"rating" json:"ratings"`
Author Person `json:"author"`
Coauthor *Person `json:"coauthor"`
HeaderImage *multipart.FileHeader `json:"-"`
Pictures []*multipart.FileHeader `form:"picture" json:"-"`
}
EmbedPerson struct {
*Person
}
Group struct {
Name string `json:"name" binding:"Required"`
People []*Person `json:"people" binding:"MinSize(1)"`
}
Everything struct {
Int int
Int8 int8
Int16 int16
Uint uint
Uint8 uint8
Uint64 uint64
Bool bool
BoolOn bool
String string
IntPointer *int
Map map[string]string
}
Rules struct {
AlphaDashDot string `binding:"AlphaDashDot"`
MinSize string `binding:"MinSize(3)"`
MinSizeSlice []string `binding:"MinSize(2)"`
MaxSize string `binding:"MaxSize(1)"`
MaxSizeSlice []string `binding:"MaxSize(1)"`
Range int `binding:"Range(1,2)"`
In string `binding:"In(a,b)"`
Include string `binding:"Include(x)"`
Pointer *string `binding:"MaxSize(1)"`
}
RequiredForm struct {
String string `binding:"Required"`
Int int `binding:"Required"`
Bool bool `binding:"Required"`
Slice []int `binding:"Required"`
Pointer *string `binding:"Required"`
PointerToZero *string `binding:"Required"`
}
AnyString string
Trimmed struct {
Value AnyString `binding:"TrimSpace;Required"`
Pointer *AnyString `binding:"TrimSpace;Required"`
}
TrimForm struct {
Trimmed
Nested Trimmed
Items []Trimmed
PointerItems []*Trimmed
}
InvalidRule struct {
Field string `binding:"InvalidRule"`
}
)
func TestBind(t *testing.T) {
const formType, jsonType = "application/x-www-form-urlencoded", "application/json"
requiredTitle := Error{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"}
unsupported := Errors{{Classification: errContentType, Message: "Unsupported Content-Type"}}
assert.Equal(t, "Required", requiredTitle.Error())
cases := []struct {
name string
method string
target string
contentType string
body string
expected any
errs Errors
}{
{name: "form missing required", contentType: formType, body: "content=C", expected: Post{Content: "C"}, errs: Errors{requiredTitle}},
{
name: "form malformed", contentType: formType, body: "title=%2", expected: Post{},
errs: Errors{{Classification: errDeserialization, Message: `invalid URL escape "%2"`}, requiredTitle},
},
{
name: "form nested and embedded", contentType: formType, body: "title=T&content=C&id=1&name=N&rating=4&rating=3&-=x&ignored=x",
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
},
{name: "query on POST", target: "/?title=T", contentType: formType, expected: Post{Title: "T"}},
{name: "query on GET", method: http.MethodGet, target: "/?title=T&content=C", expected: Post{Title: "T", Content: "C"}},
{name: "query on GET with content type", method: http.MethodGet, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
{name: "query on HEAD with content type", method: http.MethodHead, target: "/?title=T", contentType: jsonType, body: `{"title":"B"}`, expected: Post{Title: "T"}},
{name: "embedded pointer", method: http.MethodGet, target: "/?name=N&email=E", expected: EmbedPerson{&Person{Name: "N", Email: "E"}}},
{name: "embedded pointer not bound", method: http.MethodGet, target: "/", expected: EmbedPerson{}},
{name: "DELETE without content type", method: http.MethodDelete, target: "/?title=T", expected: Post{Title: "T"}},
{name: "POST without content type", target: "/?title=T", expected: Post{}, errs: unsupported},
{name: "unsupported content type", method: http.MethodPatch, contentType: "text/plain", body: "title=T", expected: Post{}, errs: unsupported},
{
name: "json ignores form tags", method: http.MethodPut, contentType: jsonType, body: `{"title":"T","content":"C","id":1,"rating":[1],"ratings":[4,3],"author":{"name":"N"}}`,
expected: BlogPost{Post: Post{Title: "T", Content: "C"}, ID: 1, Ratings: []int{4, 3}, Author: Person{Name: "N"}},
},
{
name: "json malformed", contentType: jsonType, body: `{"title":"T"`, expected: Post{Title: "T"},
errs: Errors{{Classification: errDeserialization, Message: "jsontext: unexpected EOF after offset 12"}},
},
{
name: "json null slice element", contentType: jsonType, body: `{"name":"G","people":[null,{"name":"N"}]}`,
expected: Group{Name: "G", People: []*Person{nil, {Name: "N"}}},
},
{
name: "json slice element required", contentType: jsonType, body: `{"name":"G","people":[{"email":"E"}]}`,
expected: Group{Name: "G", People: []*Person{{Email: "E"}}},
errs: Errors{{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"}},
},
{
name: "form type conversion", contentType: formType,
body: "int=-1&int8=-8&int16=&uint=1&uint8=8&uint64=64&bool=true&bool_on=on&string=s&int_pointer=7",
expected: Everything{Int: -1, Int8: -8, Uint: 1, Uint8: 8, Uint64: 64, Bool: true, BoolOn: true, String: "s", IntPointer: new(7)},
},
{
name: "form type conversion errors", contentType: formType,
body: "int=x&int8=128&uint=-1&uint8=256&bool=maybe&int_pointer=x&map=x",
expected: Everything{},
errs: Errors{
{FieldNames: []string{"Int"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Int8"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Uint"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
{FieldNames: []string{"Uint8"}, Classification: errTypeCast, Message: "Value could not be parsed as unsigned integer"},
{FieldNames: []string{"Bool"}, Classification: errTypeCast, Message: "Value could not be parsed as boolean"},
{FieldNames: []string{"IntPointer"}, Classification: errTypeCast, Message: "Value could not be parsed as integer"},
{FieldNames: []string{"Map"}, Classification: errDeserialization, Message: "unsupported type: map"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(cmp.Or(tc.method, http.MethodPost), cmp.Or(tc.target, "/"), strings.NewReader(tc.body))
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
actual := reflect.New(reflect.TypeOf(tc.expected))
assert.Equal(t, tc.errs, NewBinder().Bind(req, actual.Interface()))
assert.Equal(t, tc.expected, actual.Elem().Interface())
})
}
}
func TestBindMultipartForm(t *testing.T) {
readFile := func(t *testing.T, fileHeader *multipart.FileHeader) string {
file, err := fileHeader.Open()
require.NoError(t, err)
defer file.Close()
content, err := io.ReadAll(file)
require.NoError(t, err)
return string(content)
}
for _, parsedBefore := range []bool{false, true} {
t.Run(fmt.Sprintf("parsed before %v", parsedBefore), func(t *testing.T) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, field := range [][2]string{{"title", "T"}, {"id", "1"}, {"rating", "3"}, {"rating", "5"}, {"name", "N"}} {
require.NoError(t, writer.WriteField(field[0], field[1]))
}
for _, file := range [][2]string{{"header_image", "header.txt"}, {"picture", "a.txt"}, {"picture", "b.txt"}} {
fileWriter, err := writer.CreateFormFile(file[0], file[1])
require.NoError(t, err)
_, err = fileWriter.Write([]byte("content of " + file[1]))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if parsedBefore {
assert.Equal(t, "T", req.FormValue("title"))
}
var actual BlogPost
assert.Empty(t, NewBinder().Bind(req, &actual))
assert.Equal(t, Post{Title: "T"}, actual.Post)
assert.Equal(t, 1, actual.ID)
assert.Equal(t, []int{3, 5}, actual.Ratings)
assert.Equal(t, Person{Name: "N"}, actual.Author)
assert.Equal(t, "content of header.txt", readFile(t, actual.HeaderImage))
require.Len(t, actual.Pictures, 2)
assert.Equal(t, "content of a.txt", readFile(t, actual.Pictures[0]))
assert.Equal(t, "content of b.txt", readFile(t, actual.Pictures[1]))
})
}
for name, contentType := range map[string]string{"malformed body": "multipart/form-data; boundary=x", "missing boundary": "multipart/form-data"} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("--x\r\nbroken"))
req.Header.Set("Content-Type", contentType)
var actual Post
errs := NewBinder().Bind(req, &actual)
require.Len(t, errs, 2)
assert.Equal(t, errDeserialization, errs[0].Classification)
assert.Equal(t, ErrRequired, errs[1].Classification)
})
}
}
func TestValidate(t *testing.T) {
cases := []struct {
name string
data any
expected any
errs Errors
}{
{name: "rules skip zero values", data: &Rules{}},
{
name: "rules with valid values",
data: &Rules{AlphaDashDot: "a-b_c.d", MinSize: "abc", MinSizeSlice: []string{"a", "b"}, MaxSize: "é", MaxSizeSlice: []string{"a"}, Range: 2, In: "b", Include: "axb", Pointer: new("a")},
},
{
name: "rules with invalid values",
data: &Rules{AlphaDashDot: "a,b", MinSize: "ab", MinSizeSlice: []string{"a"}, MaxSize: "ab", MaxSizeSlice: []string{"a", "b"}, Range: 3, In: "c", Include: "abc", Pointer: new("ab")},
errs: Errors{
{FieldNames: []string{"AlphaDashDot"}, Classification: ErrAlphaDashDot, Message: "AlphaDashDot"},
{FieldNames: []string{"MinSize"}, Classification: ErrMinSize, Message: "MinSize"},
{FieldNames: []string{"MinSizeSlice"}, Classification: ErrMinSize, Message: "MinSize"},
{FieldNames: []string{"MaxSize"}, Classification: ErrMaxSize, Message: "MaxSize"},
{FieldNames: []string{"MaxSizeSlice"}, Classification: ErrMaxSize, Message: "MaxSize"},
{FieldNames: []string{"Range"}, Classification: ErrRange, Message: "Range"},
{FieldNames: []string{"In"}, Classification: ErrIn, Message: "In"},
{FieldNames: []string{"Include"}, Classification: ErrInclude, Message: "Include"},
{FieldNames: []string{"Pointer"}, Classification: ErrMaxSize, Message: "MaxSize"},
},
},
{
name: "required",
data: &RequiredForm{PointerToZero: new("")},
errs: Errors{
{FieldNames: []string{"String"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Int"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Bool"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Slice"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"PointerToZero"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "nested embedded and pointer structs",
data: &BlogPost{Coauthor: &Person{}},
errs: Errors{
{FieldNames: []string{"Title"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"ID"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Name"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "TrimSpace in nested embedded and slice elements",
data: &TrimForm{Trimmed: Trimmed{Value: " a ", Pointer: new(AnyString(" b "))}, Nested: Trimmed{Value: " "}, Items: []Trimmed{{Value: " c "}}, PointerItems: []*Trimmed{{Value: " d ", Pointer: new(AnyString(" "))}}},
expected: &TrimForm{Trimmed: Trimmed{Value: "a", Pointer: new(AnyString("b"))}, Nested: Trimmed{Value: ""}, Items: []Trimmed{{Value: "c"}}, PointerItems: []*Trimmed{{Value: "d", Pointer: new(AnyString(""))}}},
errs: Errors{
{FieldNames: []string{"Value"}, Classification: ErrRequired, Message: "Required"},
{FieldNames: []string{"Pointer"}, Classification: ErrRequired, Message: "Required"},
},
},
{
name: "TrimSpace on non-string",
data: &struct {
Float float64 `binding:"TrimSpace"`
}{Float: 1.5},
errs: Errors{{FieldNames: []string{"Float"}, Classification: errTypeCast, Message: "TrimSpace"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.errs, NewBinder().Validate(t.Context(), tc.data))
if tc.expected != nil {
assert.Equal(t, tc.expected, tc.data)
}
})
}
assert.PanicsWithValue(t, `Invalid binding rule: "InvalidRule"`, func() {
NewBinder().Validate(t.Context(), &InvalidRule{})
})
}
+11 -14
View File
@@ -14,8 +14,7 @@ import (
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
"gitea.dev/modules/validation"
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
"gitea.dev/modules/web/binding"
)
type (
@@ -125,7 +124,7 @@ func getFieldDisplayNameForMessage(f any, l translation.Locale, fieldNames []str
}
func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs validation.BindingErrors) (errorMessage, errorFieldName string, fieldNames []string) {
if bindingErrs.Len() == 0 {
if len(bindingErrs) == 0 {
return "", "", nil
}
bindingErr := bindingErrs[0]
@@ -137,30 +136,28 @@ func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs valida
errorFieldName = field.Name
switch classification {
case binding.ERR_REQUIRED:
case binding.ErrRequired:
errorMessage = l.TrString("form.require_error", fieldDisplayName)
case binding.ERR_ALPHA_DASH:
errorMessage = l.TrString("form.alpha_dash_error", fieldDisplayName)
case binding.ERR_ALPHA_DASH_DOT:
case binding.ErrAlphaDashDot:
errorMessage = l.TrString("form.alpha_dash_dot_error", fieldDisplayName)
case binding.ERR_MIN_SIZE:
case binding.ErrMinSize:
errorMessage = l.TrString("form.min_size_error", fieldDisplayName, getRuleBody(field, "MinSize"))
case binding.ERR_MAX_SIZE:
case binding.ErrMaxSize:
errorMessage = l.TrString("form.max_size_error", fieldDisplayName, getRuleBody(field, "MaxSize"))
case binding.ERR_RANGE:
case binding.ErrRange:
rangeMin, rangeMax, _ := strings.Cut(getRuleBody(field, "Range"), ",")
errorMessage = l.TrString("form.range_error", fieldDisplayName, rangeMin, rangeMax)
case binding.ERR_EMAIL:
case validation.ErrEmail:
errorMessage = l.TrString("form.email_error", fieldDisplayName)
case binding.ERR_URL:
case validation.ErrURL:
errorMessage = l.TrString("form.url_error", fieldDisplayName)
case binding.ERR_IN:
case binding.ErrIn:
ruleBody := getRuleBody(field, "In")
if strings.HasPrefix(ruleBody, ",") {
ruleBody = "(empty)" + ruleBody
}
errorMessage = l.TrString("form.in_error", fieldDisplayName, ruleBody)
case binding.ERR_INCLUDE:
case binding.ErrInclude:
errorMessage = l.TrString("form.include_error", fieldDisplayName, getRuleBody(field, "Include"))
case validation.ErrCustomMessage:
-2
View File
@@ -527,10 +527,8 @@
"form.SSPISeparatorReplacement": "Separator",
"form.SSPIDefaultLanguage": "Default Language",
"form.require_error": "%s cannot be empty.",
"form.alpha_dash_error": "%s should contain only alphanumeric, dash ('-') and underscore ('_') characters.",
"form.alpha_dash_dot_error": "%s should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.",
"form.git_ref_name_error": "%s must be a well-formed Git reference name.",
"form.size_error": "%[1]s must be size %[2]s.",
"form.min_size_error": "%[1]s must contain at least %[2]s characters.",
"form.max_size_error": "%[1]s must contain at most %[2]s characters.",
"form.range_error": "%[1]s must be a number from %[2]s to %[3]s.",
+1 -1
View File
@@ -65,6 +65,6 @@ type AdminEditUserForm struct {
// AdminDashboardForm form for admin dashboard operations
type AdminDashboardForm struct {
middleware.FormDefaultValidator
Op string `binding:"required"`
Op string `binding:"Required"`
From string
}
+1 -1
View File
@@ -57,5 +57,5 @@ type CreateTeamForm struct {
Permission string
RepoAccess string
CanCreateOrgRepo bool
Visibility string `binding:"OmitEmpty;In(public,limited,private)"`
Visibility string `binding:"In(public,limited,private)"`
}
+1 -7
View File
@@ -350,7 +350,7 @@ type CreateIssueForm struct {
type CreateCommentForm struct {
middleware.FormDefaultValidator
Content string
Status string `binding:"OmitEmpty;In(reopen,close)"`
Status string `binding:"In(reopen,close)"`
Files []string
}
@@ -571,12 +571,6 @@ type AddTimeManuallyForm struct {
Minutes int `binding:"Range(0,1000)"`
}
// SaveTopicForm form for save topics for repository
type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
// AddDeployTokenForm form for adding a deploy token to a repository
type AddDeployTokenForm struct {
middleware.FormDefaultValidator
+6 -6
View File
@@ -38,7 +38,7 @@ type InstallForm struct {
SMTPAddr string `binding:"TrimSpace"`
SMTPPort string `binding:"TrimSpace"`
SMTPFrom string `binding:"TrimSpace"`
SMTPUser string `binding:"TrimSpace;OmitEmpty;MaxSize(254)" locale:"install.mailer_user"`
SMTPUser string `binding:"TrimSpace;MaxSize(254)" locale:"install.mailer_user"`
SMTPPasswd string
RegisterConfirm bool
MailNotify bool
@@ -183,7 +183,7 @@ type AvatarForm struct {
middleware.FormDefaultValidator
Source string
Avatar *multipart.FileHeader
Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"`
Gravatar string `binding:"Email;MaxSize(254)"`
}
// AddEmailForm form for adding new email
@@ -215,12 +215,12 @@ type AddOpenIDForm struct {
// AddKeyForm form for adding SSH/GPG key
type AddKeyForm struct {
middleware.FormDefaultValidator
Type string `binding:"OmitEmpty"`
Type string
Title string `binding:"Required;MaxSize(50)"`
Content string `binding:"Required"`
Signature string `binding:"OmitEmpty"`
KeyID string `binding:"OmitEmpty"`
Fingerprint string `binding:"OmitEmpty"`
Signature string
KeyID string
Fingerprint string
IsWritable bool
}