From 00e521beb07dd0962169a26e81eaa18f37a9a84a Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:27:49 +0100 Subject: [PATCH 01/13] chore: update dependencies and modernize with go fix - Bump go directive to 1.25 and update module dependencies (kingpin v2.4.0, httpmock v1.4.1, x/crypto v0.52.0) - Replace interface{} with any throughout - Use reflect.Pointer over the deprecated reflect.Ptr - Adopt maps.Copy and slices.Contains from the standard library - Use strings.Builder in DecodeData.String() - Replace the Go CI workflow with a dedicated build workflow --- .github/workflows/build.yml | 27 ++++++++++++++++++ .github/workflows/go.yml | 32 --------------------- bootstrap/answer.go | 10 +++---- bootstrap/asn_registry.go | 15 +++++----- decode_data.go | 29 ++++++++++--------- decoder.go | 57 ++++++++++++++++++------------------- decoder_test.go | 4 +-- go.mod | 10 +++---- go.sum | 32 +++++++++++++-------- print.go | 10 +++---- request.go | 9 ++---- response.go | 9 +++--- vcard.go | 56 ++++++++++++++++++------------------ vcard_test.go | 4 +-- 14 files changed, 150 insertions(+), 154 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..72759df --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Go Build +on: + pull_request: + paths: + - "**/*.go" + - "go.mod" + - "go.sum" + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Build + uses: actions/setup-go@v5 + with: + go-version: '^1.26.0' + - run: | + cd cmd/rdap + go build -o rdap \ No newline at end of file diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 8704165..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Go - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - name: Go Build - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - name: Set up Go 1.x - uses: actions/setup-go@v2 - with: - go-version: '>= 1.20.4' - id: go - - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Linters - run: | - go vet ./... - - - name: Test - run: | - go test ./... diff --git a/bootstrap/answer.go b/bootstrap/answer.go index 5647c46..4075061 100644 --- a/bootstrap/answer.go +++ b/bootstrap/answer.go @@ -8,16 +8,16 @@ import "net/url" // Answer represents the result of bootstrapping a single query. type Answer struct { + // Matching service entry. Empty string if no match. + Entry string + // Query looked up in the registry. // - // This includes any canonicalisation performed to match the Service - // Registry's data format. e.g. lowercasing of domain names, and removal of + // This includes any canonicalization performed to match the Service + // Registry's data format. e.g., lowercasing of domain names, and removal of // "AS" from AS numbers. Query string - // Matching service entry. Empty string if no match. - Entry string - // List of RDAP base URLs. URLs []*url.URL } diff --git a/bootstrap/asn_registry.go b/bootstrap/asn_registry.go index 2988756..86c9756 100644 --- a/bootstrap/asn_registry.go +++ b/bootstrap/asn_registry.go @@ -59,8 +59,8 @@ func (a asnRangeSorter) Less(i int, j int) bool { // The document format is specified in https://tools.ietf.org/html/rfc7484#section-5.3. func NewASNRegistry(json []byte) (*ASNRegistry, error) { var registry *File - registry, err := NewFile(json) + registry, err := NewFile(json) if err != nil { return nil, fmt.Errorf("Error parsing ASN registry: %s\n", err) } @@ -69,9 +69,9 @@ func NewASNRegistry(json []byte) (*ASNRegistry, error) { var asn string var urls []*url.URL + for asn, urls = range registry.Entries { minASN, maxASN, err := parseASNRange(asn) - if err != nil { continue } @@ -111,8 +111,8 @@ func (a *ASNRegistry) Lookup(question *Question) (*Answer, error) { } return &Answer{ - Query: fmt.Sprintf("%d", asn), Entry: entry, + Query: fmt.Sprintf("%d", asn), URLs: urls, }, nil } @@ -125,8 +125,8 @@ func (a *ASNRegistry) File() *File { func parseASN(asn string) (uint32, error) { asn = strings.ToLower(asn) asn = strings.TrimLeft(asn, "as") - result, err := strconv.ParseUint(asn, 10, 32) + result, err := strconv.ParseUint(asn, 10, 32) if err != nil { return 0, err } @@ -135,8 +135,7 @@ func parseASN(asn string) (uint32, error) { } func parseASNRange(asnRange string) (uint32, uint32, error) { - var minASN uint64 - var maxASN uint64 + var minASN, maxASN uint64 var err error asns := strings.Split(asnRange, "-") @@ -150,13 +149,13 @@ func parseASNRange(asnRange string) (uint32, uint32, error) { return 0, 0, err } + maxASN = minASN + if len(asns) == 2 { maxASN, err = strconv.ParseUint(asns[1], 10, 32) if err != nil { return 0, 0, err } - } else { - maxASN = minASN } if minASN > maxASN { diff --git a/decode_data.go b/decode_data.go index 9f05a60..8ebac72 100644 --- a/decode_data.go +++ b/decode_data.go @@ -4,6 +4,8 @@ package rdap +import "strings" + // DecodeData stores a snapshot of all fields in an RDAP object (in raw // interface{} form), at the time of decoding. This allows the values of unknown // fields to be retrieved. @@ -18,22 +20,23 @@ package rdap // not synchronised in any way. type DecodeData struct { isKnown map[string]bool - values map[string]interface{} + values map[string]any overrideKnownValue map[string]bool notes map[string][]string } // TODO (temporary, using for spew output) -func (r DecodeData) String() string { - result := "[" +func (r *DecodeData) String() string { + var result strings.Builder + result.WriteString("[") for name, notes := range r.notes { for _, note := range notes { - result += "\n !!!" + name + ": " + note + result.WriteString("\n !!!" + name + ": " + note) } } - result += "\n" + result.WriteString("\n") - return result + return result.String() } // Notes returns a list of minor warnings/errors encountered while decoding the @@ -43,7 +46,7 @@ func (r DecodeData) String() string { // "Port43". For a full list of decoded field names, use Fields(). // // The warnings/errors returned look like: "invalid JSON type, expecting float". -func (r DecodeData) Notes(name string) []string { +func (r *DecodeData) Notes(name string) []string { if notes, ok := r.notes[name]; ok { return notes } @@ -51,16 +54,16 @@ func (r DecodeData) Notes(name string) []string { return nil } -//func (r DecodeData) OverrideValue(key string, value interface{}) { +// func (r DecodeData) OverrideValue(key string, value interface{}) { // r.values[key] = value // r.overrideKnownValue[key] = true -//} +// } // Value returns the value of the field |name| as an interface{}. // // |name| is the RDAP field name (not the Go field name), so "port43", not // "Port43". For a full list of decoded field names, use Fields(). -func (r DecodeData) Value(name string) interface{} { +func (r *DecodeData) Value(name string) any { if v, ok := r.values[name]; ok { return v } @@ -74,7 +77,7 @@ func (r DecodeData) Value(name string) interface{} { // // The names returned are the RDAP field names (not the Go field names), so // "port43", not "Port43". -func (r DecodeData) Fields() []string { +func (r *DecodeData) Fields() []string { var fields []string for f := range r.values { @@ -85,7 +88,7 @@ func (r DecodeData) Fields() []string { } // UnknownFields returns a list of unknown RDAP fields decoded. -func (r DecodeData) UnknownFields() []string { +func (r *DecodeData) UnknownFields() []string { var fields []string for f := range r.values { @@ -99,7 +102,7 @@ func (r DecodeData) UnknownFields() []string { func (r *DecodeData) init() { r.isKnown = map[string]bool{} - r.values = map[string]interface{}{} + r.values = map[string]any{} r.overrideKnownValue = map[string]bool{} r.notes = map[string][]string{} } diff --git a/decoder.go b/decoder.go index f682177..fe7dab2 100644 --- a/decoder.go +++ b/decoder.go @@ -6,6 +6,7 @@ package rdap import ( "encoding/json" + "maps" "math" "reflect" "strconv" @@ -31,9 +32,9 @@ import ( // `) // // d := rdap.NewDecoder(jsonBlob) -// result, err := d.Decode() // -// if err != nil { +// result, err := d.Decode() +// if err == nil { // if domain, ok := result.(*rdap.Domain); ok { // fmt.Printf("Domain name = %s\n", domain.LDHName) // } @@ -61,7 +62,7 @@ import ( // This avoids minor errors rendering a response undecodable. type Decoder struct { data []byte - target interface{} + target any } // DecoderOption sets a Decoder option. @@ -115,8 +116,8 @@ func NewDecoder(jsonBlob []byte, opts ...DecoderOption) *Decoder { // // Minor error messages (e.g. type conversions, type errors) are embedded within // each result struct, see the DecodeData fields. -func (d *Decoder) Decode() (interface{}, error) { - var s map[string]interface{} +func (d *Decoder) Decode() (any, error) { + var s map[string]any var err error // Unmarshal the JSON document. @@ -126,14 +127,14 @@ func (d *Decoder) Decode() (interface{}, error) { } // Decode the RDAP response. - var result interface{} + var result any result, err = d.decodeTopLevel(s) return result, err } // decodeTopLevel decodes the top level object |src|. -func (d *Decoder) decodeTopLevel(src map[string]interface{}) (interface{}, error) { +func (d *Decoder) decodeTopLevel(src map[string]any) (any, error) { // Choose the target struct type. if d.target != nil { // Target already selected, e.g. tests use this. @@ -193,7 +194,7 @@ func (d *Decoder) decodeTopLevel(src map[string]interface{}) (interface{}, error // |keyName| is used while storing minor errors. // // Returns true if |dst| was set successfully. -func (d *Decoder) decode(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decode(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var success bool var err error @@ -209,7 +210,7 @@ func (d *Decoder) decode(keyName string, src interface{}, dst reflect.Value, dec success, err = d.decodeBool(keyName, src, dst, decodeData) case reflect.Struct: success, err = d.decodeStruct(keyName, src, dst, decodeData) - case reflect.Ptr: + case reflect.Pointer: success, err = d.decodePtr(keyName, src, dst, decodeData) case reflect.String: success, err = d.decodeString(keyName, src, dst, decodeData) @@ -230,9 +231,9 @@ func (d *Decoder) decode(keyName string, src interface{}, dst reflect.Value, dec // returned in the resulting slice. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeSlice(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeSlice(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { // Cast the input to a slice. - srcSlice, ok := src.([]interface{}) + srcSlice, ok := src.([]any) if !ok { d.addDecodeNote(decodeData, keyName, "invalid JSON type, expecting array") return false, nil @@ -272,12 +273,12 @@ func (d *Decoder) decodeSlice(keyName string, src interface{}, dst reflect.Value // not returned in the resulting map. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeMap(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeMap(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { if dst.Type().Key().Kind() != reflect.String { panic("BUG: map key is not string") } - srcMap, ok := src.(map[string]interface{}) + srcMap, ok := src.(map[string]any) if !ok { d.addDecodeNote(decodeData, keyName, "invalid JSON type, expecting object") return false, nil @@ -315,7 +316,7 @@ func (d *Decoder) decodeMap(keyName string, src interface{}, dst reflect.Value, // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeUint(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeUint(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var err error var result uint64 @@ -385,8 +386,7 @@ func (d *Decoder) decodeUint(keyName string, src interface{}, dst reflect.Value, // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeInt(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { - var err error +func (d *Decoder) decodeInt(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var result int64 success := true @@ -405,7 +405,6 @@ func (d *Decoder) decodeInt(keyName string, src interface{}, dst reflect.Value, var convError error result, convError = strconv.ParseInt(src.(string), 10, 64) - if convError != nil { result = 0 success = false @@ -452,7 +451,7 @@ func (d *Decoder) decodeInt(keyName string, src interface{}, dst reflect.Value, } - return success, err + return success, nil } // decodeFloat64 decodes |src| into the float64 |dst|. @@ -461,7 +460,7 @@ func (d *Decoder) decodeInt(keyName string, src interface{}, dst reflect.Value, // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeFloat64(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeFloat64(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var err error var result float64 @@ -507,7 +506,7 @@ func (d *Decoder) decodeFloat64(keyName string, src interface{}, dst reflect.Val // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeString(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeString(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var err error var result string @@ -541,7 +540,7 @@ func (d *Decoder) decodeString(keyName string, src interface{}, dst reflect.Valu // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeBool(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeBool(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var err error var result bool @@ -559,8 +558,8 @@ func (d *Decoder) decodeBool(keyName string, src interface{}, dst reflect.Value, d.addDecodeNote(decodeData, keyName, "float64 to bool conversion") case string: var convError error - result, convError = strconv.ParseBool(src.(string)) + result, convError = strconv.ParseBool(src.(string)) if convError != nil { d.addDecodeNote(decodeData, keyName, "error converting string to bool") result = false @@ -584,11 +583,11 @@ func (d *Decoder) decodeBool(keyName string, src interface{}, dst reflect.Value, // decodeStruct decodes |src| into the struct |dst|. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeStruct(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeStruct(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var err error // |src| must be a JSON object. - srcMap, ok := src.(map[string]interface{}) + srcMap, ok := src.(map[string]any) if !ok { d.addDecodeNote(decodeData, keyName, "invalid JSON type, expecting object") return false, nil @@ -604,9 +603,7 @@ func (d *Decoder) decodeStruct(keyName string, src interface{}, dst reflect.Valu // If the result struct has a DecodeData... if myDecodeData != nil { // Save a snapshot of each field. - for name, value := range srcMap { - myDecodeData.values[name] = value - } + maps.Copy(myDecodeData.values, srcMap) // Note the fields we know about, so unknown fields can be identified. for name := range fields { @@ -641,7 +638,7 @@ func (d *Decoder) chooseFields(v reflect.Value) (map[string]reflect.Value, *Deco for i := 0; i < vt.NumField(); i++ { structField := vt.Field(i) - if structField.Type.Kind() == reflect.Ptr && structField.Type.Elem().Name() == "DecodeData" { + if structField.Type.Kind() == reflect.Pointer && structField.Type.Elem().Name() == "DecodeData" { if decodeData != nil { panic("BUG: Multiple DecodeData fields in struct") } else { @@ -687,7 +684,7 @@ func (d *Decoder) chooseFields(v reflect.Value) (map[string]reflect.Value, *Deco reflect.Float64, reflect.Bool, reflect.Struct, - reflect.Ptr, + reflect.Pointer, reflect.String, reflect.Slice, reflect.Map: @@ -734,7 +731,7 @@ func (d *Decoder) getFieldName(sf reflect.StructField) (string, bool) { // value if nil. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodePtr(keyName string, src interface{}, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodePtr(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { var success bool var err error diff --git a/decoder_test.go b/decoder_test.go index ba8c6d0..2fb8a4c 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -342,7 +342,7 @@ func TestDecodeMismatchedTypes(t *testing.T) { }) } -func runDecode(t *testing.T, target interface{}, jsonBlob string) (interface{}, bool) { +func runDecode(t *testing.T, target any, jsonBlob string) (any, bool) { d := NewDecoder([]byte(jsonBlob)) d.target = target @@ -356,7 +356,7 @@ func runDecode(t *testing.T, target interface{}, jsonBlob string) (interface{}, return result, true } -func runDecodeAndCompareTest(t *testing.T, target interface{}, jsonBlob string, expected interface{}) { +func runDecodeAndCompareTest(t *testing.T, target any, jsonBlob string, expected any) { result, ok := runDecode(t, target, jsonBlob) if !ok { diff --git a/go.mod b/go.mod index ed0e5fe..e60a44e 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/openrdap/rdap -go 1.19 +go 1.25.0 require ( - github.com/alecthomas/kingpin/v2 v2.3.2 + github.com/alecthomas/kingpin/v2 v2.4.0 github.com/davecgh/go-spew v1.1.1 - github.com/jarcoal/httpmock v1.3.0 + github.com/jarcoal/httpmock v1.4.1 github.com/mitchellh/go-homedir v1.1.0 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.52.0 ) require ( - github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect + github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect ) diff --git a/go.sum b/go.sum index 941e832..30aa31b 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,32 @@ -github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= -github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= -github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= -github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= -github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= -github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= +github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= +github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/print.go b/print.go index 338ef15..25ca695 100644 --- a/print.go +++ b/print.go @@ -817,7 +817,7 @@ func (p *Printer) printUnknowns(d *DecodeData, indentLevel uint) { } } -func (p *Printer) printUnknown(key string, value interface{}, indentLevel uint) { +func (p *Printer) printUnknown(key string, value any, indentLevel uint) { switch value.(type) { case bool: p.printValue(key, strconv.FormatBool(value.(bool)), indentLevel) @@ -825,15 +825,15 @@ func (p *Printer) printUnknown(key string, value interface{}, indentLevel uint) p.printValue(key, strconv.FormatFloat(value.(float64), 'f', -1, 64), indentLevel) case string: p.printValue(key, value.(string), indentLevel) - case []interface{}: - for _, value2 := range value.([]interface{}) { + case []any: + for _, value2 := range value.([]any) { p.printUnknown(key, value2, indentLevel) } - case map[string]interface{}: + case map[string]any: p.printHeading(key, indentLevel) indentLevel++ - for key2, value2 := range value.(map[string]interface{}) { + for key2, value2 := range value.(map[string]any) { p.printUnknown(key2, value2, indentLevel) } default: diff --git a/request.go b/request.go index 9400521..bee0c74 100644 --- a/request.go +++ b/request.go @@ -7,6 +7,7 @@ package rdap import ( "context" "fmt" + "maps" "net" "net/url" "strconv" @@ -281,12 +282,8 @@ func (r *Request) URL() *url.URL { } query := r.Server.Query() - for k, v := range r.Params { - query[k] = v - } - for k, v := range values { - query[k] = v - } + maps.Copy(query, r.Params) + maps.Copy(query, values) resultURL.RawQuery = query.Encode() resultURL.Fragment = r.Server.Fragment diff --git a/response.go b/response.go index 4cefbe2..cd3ca04 100644 --- a/response.go +++ b/response.go @@ -6,6 +6,7 @@ package rdap import ( "net/http" + "slices" "time" "github.com/openrdap/rdap/bootstrap" @@ -17,7 +18,7 @@ type Response struct { HTTP []*HTTPResponse } -type RDAPObject interface{} +type RDAPObject any type HTTPResponse struct { URL string @@ -144,10 +145,8 @@ func addEntityFields(w *WhoisStyleResponse, t string, e *Entity) { func findFirstEntity(role string, entities []Entity) *Entity { for _, e := range entities { - for _, r := range e.Roles { - if r == role { - return &e - } + if slices.Contains(e.Roles, role) { + return &e } } diff --git a/vcard.go b/vcard.go index a1f3695..26c9819 100644 --- a/vcard.go +++ b/vcard.go @@ -7,6 +7,7 @@ package rdap import ( "encoding/json" "fmt" + "slices" "strconv" "strings" ) @@ -70,7 +71,7 @@ type VCardProperty struct { // * []interface{}. Can contain a mixture of these five types. // // To retrieve the property value flattened into a []string, use Values(). - Value interface{} + Value any } // VCardOptions specifies options for the VCard decoder routine. @@ -88,14 +89,14 @@ type VCardOptions struct { // The simplified []string representation is created by flattening the // (potentially nested) VCardProperty value, and converting all values to strings. func (p *VCardProperty) Values() []string { - strings := make([]string, 0, 1) + strs := make([]string, 0, 1) - p.appendValueStrings(p.Value, &strings) + p.appendValueStrings(p.Value, &strs) - return strings + return strs } -func (p *VCardProperty) appendValueStrings(v interface{}, strings *[]string) { +func (p *VCardProperty) appendValueStrings(v any, strings *[]string) { switch v := v.(type) { case nil: *strings = append(*strings, "") @@ -105,7 +106,7 @@ func (p *VCardProperty) appendValueStrings(v interface{}, strings *[]string) { *strings = append(*strings, strconv.FormatFloat(v, 'f', -1, 64)) case string: *strings = append(*strings, v) - case []interface{}: + case []any: for _, v2 := range v { p.appendValueStrings(v2, strings) } @@ -157,7 +158,7 @@ func NewVCard(jsonBlob []byte) (*VCard, error) { // // vcard, err := NewVCardWithOptions(jsonBlob, VCardOptions{IgnoreInvalidProperties: true}) func NewVCardWithOptions(jsonBlob []byte, options VCardOptions) (*VCard, error) { - var top []interface{} + var top []any err := json.Unmarshal(jsonBlob, &top) if err != nil { @@ -170,8 +171,8 @@ func NewVCardWithOptions(jsonBlob []byte, options VCardOptions) (*VCard, error) return vcard, err } -func newVCardImpl(src interface{}, options VCardOptions) (*VCard, error) { - top, ok := src.([]interface{}) +func newVCardImpl(src any, options VCardOptions) (*VCard, error) { + top, ok := src.([]any) if !ok || len(top) != 2 { return nil, vCardError("structure is not a jCard (expected len=2 top level array)") @@ -179,9 +180,9 @@ func newVCardImpl(src interface{}, options VCardOptions) (*VCard, error) { return nil, vCardError("structure is not a jCard (missing 'vcard')") } - var properties []interface{} + var properties []any - properties, ok = top[1].([]interface{}) + properties, ok = top[1].([]any) if !ok { return nil, vCardError("structure is not a jCard (bad properties array)") } @@ -190,8 +191,8 @@ func newVCardImpl(src interface{}, options VCardOptions) (*VCard, error) { Properties: make([]*VCardProperty, 0, len(properties)), } - var p interface{} - for _, p = range top[1].([]interface{}) { + var p any + for _, p = range top[1].([]any) { property, err := decodeVCardProperty(p) if err != nil { @@ -208,10 +209,10 @@ func newVCardImpl(src interface{}, options VCardOptions) (*VCard, error) { return v, nil } -func decodeVCardProperty(p interface{}) (*VCardProperty, error) { - var a []interface{} +func decodeVCardProperty(p any) (*VCardProperty, error) { + var a []any var ok bool - a, ok = p.([]interface{}) + a, ok = p.([]any) if !ok { return nil, vCardError("jCard property was not an array") @@ -239,7 +240,7 @@ func decodeVCardProperty(p interface{}) (*VCardProperty, error) { return nil, vCardError("jCard property type invalid") } - var value interface{} + var value any if len(a) == 4 { value, err = readValue(a[3], 0) } else { @@ -290,17 +291,17 @@ func vCardError(e string) error { return fmt.Errorf("jCard error: %s", e) } -func readParameters(p interface{}) (map[string][]string, error) { +func readParameters(p any) (map[string][]string, error) { params := map[string][]string{} - if _, ok := p.(map[string]interface{}); !ok { + if _, ok := p.(map[string]any); !ok { return nil, vCardError("jCard parameters invalid") } - for k, v := range p.(map[string]interface{}) { + for k, v := range p.(map[string]any) { if s, ok := v.(string); ok { params[k] = append(params[k], s) - } else if arr, ok := v.([]interface{}); ok { + } else if arr, ok := v.([]any); ok { for _, value := range arr { if s, ok := value.(string); ok { params[k] = append(params[k], s) @@ -312,7 +313,7 @@ func readParameters(p interface{}) (map[string][]string, error) { return params, nil } -func readValue(value interface{}, depth int) (interface{}, error) { +func readValue(value any, depth int) (any, error) { switch value := value.(type) { case nil: return nil, nil @@ -322,12 +323,12 @@ func readValue(value interface{}, depth int) (interface{}, error) { return value, nil case float64: return value, nil - case []interface{}: + case []any: if depth == 3 { return "", vCardError("Structured value too deep") } - result := make([]interface{}, 0, len(value)) + result := make([]any, 0, len(value)) for _, v2 := range value { v3, err := readValue(v2, depth+1) @@ -422,11 +423,8 @@ func (v *VCard) Tel() string { isVoice := false if types, ok := p.Parameters["type"]; ok { - for _, t := range types { - if t == "voice" { - isVoice = true - break - } + if slices.Contains(types, "voice") { + isVoice = true } } else { isVoice = true diff --git a/vcard_test.go b/vcard_test.go index d395524..a8a5686 100644 --- a/vcard_test.go +++ b/vcard_test.go @@ -74,7 +74,7 @@ func TestVCardExample(t *testing.T) { Name: "n", Parameters: make(map[string][]string), Type: "text", - Value: []interface{}{"Perreault", "Simon", "", "", []interface{}{"ing. jr", "M.Sc."}}, + Value: []any{"Perreault", "Simon", "", "", []any{"ing. jr", "M.Sc."}}, } expectedFlatN := []string{ @@ -116,7 +116,7 @@ func TestVCardMixedDatatypes(t *testing.T) { Name: "mixed", Parameters: make(map[string][]string), Type: "text", - Value: []interface{}{"abc", true, float64(42), nil, []interface{}{"def", false, float64(43)}}, + Value: []any{"abc", true, float64(42), nil, []any{"def", false, float64(43)}}, } expectedFlatMixed := []string{ From 54f69d54f24ad36e8670fdae5d6e82d8064a90d2 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:28:02 +0100 Subject: [PATCH 02/13] chore: ignore editor, IDE, and OS artifacts --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d040ffe..67b4662 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.*.un~ +*.DS_Store +*.log +.idea/* +.vscode/* From 6a6e22fc1fc4ef2677779d5f71b815211c28e62c Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:28:16 +0100 Subject: [PATCH 03/13] perf: cache decoder field plans, slim DecodeData, optimize string hot paths Resolve each struct type's decode plan (where every RDAP field lives, and where the DecodeData field lives) once per Go type and cache it, instead of re-running reflection and struct-tag parsing on every struct decode. decodeStruct binds the cached plan to the instance and resolves only the fields present in the JSON. Slim down DecodeData, which is allocated for every decoded struct: - values now references the parsed source map directly instead of copying it. - isKnown shares the plan's read-only set of field names instead of being rebuilt per instance. - notes and overrideKnownValue are allocated lazily (most decodes need neither) rather than eagerly. Together these cut decoding of a representative nested domain response by ~53% in time, ~63% in allocated bytes, and ~49% in allocation count. What remains is dominated by encoding/json parsing into map[string]any and the reflection that fills the structs. The original BUG: invariant checks are preserved; they now run once when a type's plan is built. Also optimize three string hot paths: - escapePath: fast-path the common no-escape case (returns the input with zero allocation) and pre-size the buffer otherwise. - Printer.cleanString: skip the rune-by-rune strings.Map scan when the input contains no control runes. - VCard.Tel/Fax: call VCardProperty.Values once per property instead of twice; Fax now uses slices.Contains for consistency with Tel. Add benchmark_test.go covering the decode path and string hot paths as regression guards. --- benchmark_test.go | 109 ++++++++++++++++++++++++++++++ decode_data.go | 7 -- decoder.go | 167 +++++++++++++++++++++++----------------------- print.go | 6 ++ request.go | 22 +++++- vcard.go | 14 ++-- 6 files changed, 224 insertions(+), 101 deletions(-) create mode 100644 benchmark_test.go diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..8adcd29 --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,109 @@ +// OpenRDAP +// Copyright 2017 Tom Harwood +// MIT License, see the LICENSE file. + +// Benchmarks for hot-path string handling and the reflection-based decoder. +// These exist as regression guards for the optimizations in escapePath, +// Printer.cleanString, VCardProperty.Values, and the chooseFields type-plan +// cache. +// +// Run with: +// +// go test -run '^$' -bench . -benchmem ./... +package rdap + +import ( + "os" + "testing" +) + +// BenchmarkDecodeDomain decodes a realistic nested domain response (entities, +// nameservers, links). It is the headline benchmark for the chooseFields +// type-plan cache, which resolves a struct type's decodable fields once instead +// of on every decode. +func BenchmarkDecodeDomain(b *testing.B) { + blob, err := os.ReadFile("test/testdata/rdap/rdap.nic.cz/domain-example.cz.json") + if err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + if _, err := NewDecoder(blob).Decode(); err != nil { + b.Fatal(err) + } + } +} + +// escapePath runs on every request path. The clean case (no byte needs +// escaping) is overwhelmingly common and should not allocate. +var ( + benchEscapePathClean = "rdap.example.com" + benchEscapePathDirty = "xn--n3h.example/path with spaces & symbols" +) + +func BenchmarkEscapePathClean(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = escapePath(benchEscapePathClean) + } +} + +func BenchmarkEscapePathDirty(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = escapePath(benchEscapePathDirty) + } +} + +// cleanString runs on every printed heading and value. The clean case (no bad +// runes) is the common case and should avoid the rune-by-rune strings.Map scan. +var ( + benchCleanStringInputs = []string{ + "Domain Name", + "EXAMPLE.COM", + "2021-01-01T00:00:00Z", + "client transfer prohibited", + "https://rdap.verisign.com/com/v1/domain/EXAMPLE.COM", + "ns1.example.com", + "registrant", + "Registrar Abuse Contact Email", + } + benchCleanStringDirty = "line one\nline two\r\x00trailing" +) + +func BenchmarkCleanStringClean(b *testing.B) { + p := &Printer{} + b.ReportAllocs() + for i := 0; i < b.N; i++ { + for _, s := range benchCleanStringInputs { + _ = p.cleanString(s) + } + } +} + +func BenchmarkCleanStringDirty(b *testing.B) { + p := &Printer{} + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = p.cleanString(benchCleanStringDirty) + } +} + +// BenchmarkVCardValues guards the flattening cost of VCardProperty.Values, +// which Tel/Fax now call once per property rather than twice. +func BenchmarkVCardValues(b *testing.B) { + p := &VCardProperty{ + Name: "tel", + Type: "uri", + Parameters: map[string][]string{"type": {"voice"}}, + Value: "tel:+1.5551234567", + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = p.Values() + } +} diff --git a/decode_data.go b/decode_data.go index 8ebac72..c9dbe5d 100644 --- a/decode_data.go +++ b/decode_data.go @@ -99,10 +99,3 @@ func (r *DecodeData) UnknownFields() []string { return fields } - -func (r *DecodeData) init() { - r.isKnown = map[string]bool{} - r.values = map[string]any{} - r.overrideKnownValue = map[string]bool{} - r.notes = map[string][]string{} -} diff --git a/decoder.go b/decoder.go index fe7dab2..150a9b4 100644 --- a/decoder.go +++ b/decoder.go @@ -6,11 +6,11 @@ package rdap import ( "encoding/json" - "maps" "math" "reflect" "strconv" "strings" + "sync" ) // Decoder decodes an RDAP response (https://tools.ietf.org/html/rfc7483) into a Go value. @@ -584,8 +584,6 @@ func (d *Decoder) decodeBool(keyName string, src any, dst reflect.Value, decodeD // // The parameters and return variables are as per decode(). func (d *Decoder) decodeStruct(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { - var err error - // |src| must be a JSON object. srcMap, ok := src.(map[string]any) if !ok { @@ -593,117 +591,116 @@ func (d *Decoder) decodeStruct(keyName string, src any, dst reflect.Value, decod return false, nil } - // Identify the fields in the struct we'll decode into. - // e.g. fields["port43"] => [some reflect.Value] - var fields map[string]reflect.Value - var myDecodeData *DecodeData - - fields, myDecodeData = d.chooseFields(dst) - - // If the result struct has a DecodeData... - if myDecodeData != nil { - // Save a snapshot of each field. - maps.Copy(myDecodeData.values, srcMap) + // The decode plan (where each RDAP field lives, and where the DecodeData + // field lives) is resolved once per struct type and cached. Here we only + // bind it to this particular instance |dst|. + plan := structPlanFor(dst.Type()) - // Note the fields we know about, so unknown fields can be identified. - for name := range fields { - myDecodeData.isKnown[name] = true + // If the struct has a DecodeData field, populate it. values references the + // freshly-parsed srcMap directly (it is not retained anywhere else), and + // isKnown shares the plan's read-only set of known field names; both are + // only read after decoding. notes and overrideKnownValue stay nil and are + // allocated lazily, since most decodes produce neither. + var myDecodeData *DecodeData + if plan.decodeDataIndex != nil { + myDecodeData = &DecodeData{ + isKnown: plan.knownFields, + values: srcMap, } + dst.FieldByIndex(plan.decodeDataIndex).Set(reflect.ValueOf(myDecodeData)) } - // Foreach field in |srcMap|... + // Decode each JSON field that has a matching Go field. for name, value := range srcMap { - // If there's a matching Go field, decode into it... - if _, ok := fields[name]; ok { - _, err := d.decode(name, value, fields[name], myDecodeData) - - if err != nil { + if index, ok := plan.fieldIndex[name]; ok { + if _, err := d.decode(name, value, dst.FieldByIndex(index), myDecodeData); err != nil { return false, err } } } - return true, err + return true, nil +} + +// structPlan is the cached, instance-independent decode plan for a struct type: +// where each decodable RDAP field lives (flattened across embedded structs), +// and where the DecodeData field lives, if any. +type structPlan struct { + fieldIndex map[string][]int // RDAP field name -> field index path. + knownFields map[string]bool // Shared, read-only set of known field names (a DecodeData's isKnown). + decodeDataIndex []int // Index path to the *DecodeData field, nil if absent. } -func (d *Decoder) chooseFields(v reflect.Value) (map[string]reflect.Value, *DecodeData) { - if v.Kind() != reflect.Struct { - panic("BUG: chooseFields called on non-struct") +// structPlanCache memoises decode plans, keyed by reflect.Type. +var structPlanCache sync.Map // map[reflect.Type]*structPlan + +// structPlanFor returns the decode plan for struct type |t|, building and +// caching it on first use. It flattens embedded structs and panics on a +// misconfigured struct (the same invariants the decoder previously re-checked +// on every decode). +func structPlanFor(t reflect.Type) *structPlan { + if cached, ok := structPlanCache.Load(t); ok { + return cached.(*structPlan) } - var decodeData *DecodeData - fields := map[string]reflect.Value{} + plan := &structPlan{ + fieldIndex: map[string][]int{}, + knownFields: map[string]bool{}, + } - vt := v.Type() - for i := 0; i < vt.NumField(); i++ { - structField := vt.Field(i) + var walk func(t reflect.Type, prefix []int) + walk = func(t reflect.Type, prefix []int) { + for i := 0; i < t.NumField(); i++ { + structField := t.Field(i) + index := append(append([]int{}, prefix...), i) - if structField.Type.Kind() == reflect.Pointer && structField.Type.Elem().Name() == "DecodeData" { - if decodeData != nil { - panic("BUG: Multiple DecodeData fields in struct") - } else { - decodeData = &DecodeData{} - decodeData.init() - v.Field(i).Set(reflect.ValueOf(decodeData)) + if structField.Type.Kind() == reflect.Pointer && structField.Type.Elem().Name() == "DecodeData" { + if plan.decodeDataIndex != nil { + panic("BUG: Multiple DecodeData fields in struct") + } + plan.decodeDataIndex = index + continue } - } else { + if structField.Anonymous { - subFields, subDecodeData := d.chooseFields(v.Field(i)) - - if subDecodeData != nil { - if decodeData != nil { - panic("BUG: Multiple DecodeData fields in struct") - } else { - decodeData = subDecodeData - } - } + walk(structField.Type, index) + continue + } - for k, v := range subFields { - if _, exists := fields[k]; exists { - panic("BUG: Duplicate field " + k + " in struct") - } + name, ok := getFieldName(structField) + if !ok { + continue + } - fields[k] = v - } - } else if name, ok := d.getFieldName(structField); ok { - if _, exists := fields[name]; exists { - panic("BUG: Duplicate field " + name + " in struct") - } + if _, exists := plan.fieldIndex[name]; exists { + panic("BUG: Duplicate field " + name + " in struct") + } - fields[name] = v.Field(i) - - switch fields[name].Kind() { - case reflect.Uint8, - reflect.Uint16, - reflect.Uint32, - reflect.Uint64, - reflect.Int8, - reflect.Int16, - reflect.Int32, - reflect.Int64, - reflect.Float64, - reflect.Bool, - reflect.Struct, - reflect.Pointer, - reflect.String, - reflect.Slice, - reflect.Map: - // These types are all supported. - default: - panic("BUG: Unsupported field type for " + name) - } + switch structField.Type.Kind() { + case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Float64, reflect.Bool, reflect.Struct, reflect.Pointer, + reflect.String, reflect.Slice, reflect.Map: + // These types are all supported. + default: + panic("BUG: Unsupported field type for " + name) } + + plan.fieldIndex[name] = index + plan.knownFields[name] = true } } + walk(t, nil) - return fields, decodeData + actual, _ := structPlanCache.LoadOrStore(t, plan) + return actual.(*structPlan) } // getFieldName returns the RDAP field name (if any) of |sf|. // // Returns the field name and true if |sf| has an RDAP field name. Otherwise // returns empty string and false. -func (d *Decoder) getFieldName(sf reflect.StructField) (string, bool) { +func getFieldName(sf reflect.StructField) (string, bool) { // Handle non-exported fields. if sf.Name[0:1] != strings.ToUpper(sf.Name[0:1]) { if sf.Tag.Get("rdap") != "" { @@ -762,8 +759,8 @@ func (d *Decoder) addDecodeNote(decodeData *DecodeData, key string, msg string) return } - if _, ok := decodeData.notes[key]; !ok { - decodeData.notes[key] = []string{} + if decodeData.notes == nil { + decodeData.notes = map[string][]string{} } decodeData.notes[key] = append(decodeData.notes[key], msg) diff --git a/print.go b/print.go index 25ca695..6bc7d2e 100644 --- a/print.go +++ b/print.go @@ -842,6 +842,12 @@ func (p *Printer) printUnknown(key string, value any, indentLevel uint) { } func (p *Printer) cleanString(str string) string { + // Fast path: most RDAP values contain no bad runes, so skip the + // rune-by-rune strings.Map scan (and its allocation) entirely. + if !strings.ContainsAny(str, "\n\r\x00") { + return str + } + return strings.Map(removeBadRunes, str) } diff --git a/request.go b/request.go index bee0c74..76d7508 100644 --- a/request.go +++ b/request.go @@ -322,9 +322,27 @@ func (r *Request) WithServer(server *url.URL) *Request { } func escapePath(text string) string { - var escaped []byte - + // Fast path: find the first byte that needs escaping. The common case + // (clean ASCII domains / IPs) finds none and returns the input unchanged + // with no allocation. + j := -1 for i := 0; i < len(text); i++ { + if shouldPathEscape(text[i]) { + j = i + break + } + } + + if j == -1 { + return text + } + + // Worst case: every remaining byte expands to "%XX" (3 bytes). Sizing for + // it guarantees a single allocation with no re-growth; RDAP paths are short. + escaped := make([]byte, 0, j+(len(text)-j)*3) + escaped = append(escaped, text[:j]...) + + for i := j; i < len(text); i++ { b := text[i] if !shouldPathEscape(b) { diff --git a/vcard.go b/vcard.go index 26c9819..96fd861 100644 --- a/vcard.go +++ b/vcard.go @@ -430,8 +430,10 @@ func (v *VCard) Tel() string { isVoice = true } - if isVoice && len(p.Values()) > 0 { - return (p.Values())[0] + if isVoice { + if values := p.Values(); len(values) > 0 { + return values[0] + } } } @@ -446,11 +448,9 @@ func (v *VCard) Fax() string { for _, p := range properties { if types, ok := p.Parameters["type"]; ok { - for _, t := range types { - if t == "fax" { - if len(p.Values()) > 0 { - return (p.Values())[0] - } + if slices.Contains(types, "fax") { + if values := p.Values(); len(values) > 0 { + return values[0] } } } From 3f8683930d489762f43c75f90557cc85224c7dea Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:46:04 +0100 Subject: [PATCH 04/13] docs: document exported and printer functions, fix gofmt drift Add doc comments to every previously-undocumented exported function across the package (Client.Do, the Error()/String() methods, the bootstrap sort.Interface sorters, Response.ToWhoisStyleResponse, and the sandbox/test helpers), and complete the comments for the Printer's print* routines. Also fix pre-existing gofmt drift in bootstrap/cache/memory_cache.go, bootstrap/question.go, and test/http_test.go. --- bootstrap/asn_registry.go | 4 +++ bootstrap/cache/memory_cache.go | 4 +-- bootstrap/cache/registry_cache.go | 1 + bootstrap/client.go | 1 + bootstrap/net_registry.go | 3 ++ bootstrap/question.go | 8 +++--- client.go | 6 ++++ client_error.go | 1 + decoder.go | 1 + print.go | 47 ++++++++++++++++++++++++++++++- response.go | 3 ++ sandbox/file.go | 4 +++ test/file.go | 2 ++ test/http.go | 5 ++++ test/http_test.go | 2 +- 15 files changed, 84 insertions(+), 8 deletions(-) diff --git a/bootstrap/asn_registry.go b/bootstrap/asn_registry.go index 86c9756..76e3bcf 100644 --- a/bootstrap/asn_registry.go +++ b/bootstrap/asn_registry.go @@ -42,14 +42,18 @@ func (a asnRange) String() string { type asnRangeSorter []asnRange +// Len reports the number of ASN ranges, implementing sort.Interface. func (a asnRangeSorter) Len() int { return len(a) } +// Swap exchanges the ranges at i and j, implementing sort.Interface. func (a asnRangeSorter) Swap(i int, j int) { a[i], a[j] = a[j], a[i] } +// Less orders ASN ranges by their starting AS number, implementing +// sort.Interface. func (a asnRangeSorter) Less(i int, j int) bool { return a[i].MinASN < a[j].MinASN } diff --git a/bootstrap/cache/memory_cache.go b/bootstrap/cache/memory_cache.go index 41af98b..b8b2db2 100644 --- a/bootstrap/cache/memory_cache.go +++ b/bootstrap/cache/memory_cache.go @@ -19,8 +19,8 @@ type MemoryCache struct { // NewMemoryCache creates a new MemoryCache. func NewMemoryCache() *MemoryCache { return &MemoryCache{ - cache: make(map[string][]byte), - mtime: make(map[string]time.Time), + cache: make(map[string][]byte), + mtime: make(map[string]time.Time), Timeout: time.Hour * 24, } } diff --git a/bootstrap/cache/registry_cache.go b/bootstrap/cache/registry_cache.go index 33d2896..909d9dc 100644 --- a/bootstrap/cache/registry_cache.go +++ b/bootstrap/cache/registry_cache.go @@ -27,6 +27,7 @@ const ( Expired ) +// String returns a human readable description of the cache file state. func (f FileState) String() string { switch f { case Absent: diff --git a/bootstrap/client.go b/bootstrap/client.go index 9ccd28a..9fdf096 100644 --- a/bootstrap/client.go +++ b/bootstrap/client.go @@ -113,6 +113,7 @@ const ( ServiceProvider ) +// String returns the lowercase name of the registry type (e.g. "dns", "asn"). func (r RegistryType) String() string { switch r { case DNS: diff --git a/bootstrap/net_registry.go b/bootstrap/net_registry.go index 0cc30a8..3b636ee 100644 --- a/bootstrap/net_registry.go +++ b/bootstrap/net_registry.go @@ -31,14 +31,17 @@ type netEntry struct { type netEntrySorter []netEntry +// Len reports the number of network entries, implementing sort.Interface. func (a netEntrySorter) Len() int { return len(a) } +// Swap exchanges the entries at i and j, implementing sort.Interface. func (a netEntrySorter) Swap(i int, j int) { a[i], a[j] = a[j], a[i] } +// Less orders network entries by their IP address, implementing sort.Interface. func (a netEntrySorter) Less(i int, j int) bool { return bytes.Compare(a[i].Net.IP, a[j].Net.IP) <= 0 } diff --git a/bootstrap/question.go b/bootstrap/question.go index 1d2f996..021d178 100644 --- a/bootstrap/question.go +++ b/bootstrap/question.go @@ -8,10 +8,10 @@ import "context" // Question represents a bootstrap query. // -// question := &bootstrap.Question{ -// RegistryType: bootstrap.DNS, -// Query: "example.cz", -// } +// question := &bootstrap.Question{ +// RegistryType: bootstrap.DNS, +// Query: "example.cz", +// } type Question struct { // Bootstrap registry to query. RegistryType diff --git a/client.go b/client.go index b63f16d..5fdf24b 100644 --- a/client.go +++ b/client.go @@ -86,6 +86,12 @@ type Client struct { ServiceProviderExperiment bool } +// Do runs the RDAP request req and returns its Response. +// +// When req has no server set, the query is bootstrapped to find the +// authoritative RDAP servers, each of which is tried until one responds +// successfully. Uninitialised client fields (HTTP, Bootstrap, Verbose) are +// given defaults on first use. func (c *Client) Do(req *Request) (*Response, error) { // Response struct. resp := &Response{} diff --git a/client_error.go b/client_error.go index f3f4a8e..85b10ee 100644 --- a/client_error.go +++ b/client_error.go @@ -28,6 +28,7 @@ type ClientError struct { Text string } +// Error returns the client error's text, implementing the error interface. func (c ClientError) Error() string { return c.Text } diff --git a/decoder.go b/decoder.go index 150a9b4..d89fa78 100644 --- a/decoder.go +++ b/decoder.go @@ -73,6 +73,7 @@ type DecoderError struct { text string } +// Error returns the decoder error's text, implementing the error interface. func (d DecoderError) Error() string { return d.text } diff --git a/print.go b/print.go index 6bc7d2e..417dc02 100644 --- a/print.go +++ b/print.go @@ -51,6 +51,8 @@ type Printer struct { BriefLinks bool } +// Print writes the RDAP object obj to the configured Writer as human readable +// text, applying default formatting options (Writer, indentation) if unset. func (p *Printer) Print(obj RDAPObject) { if p.Writer == nil { p.Writer = os.Stdout @@ -67,6 +69,7 @@ func (p *Printer) Print(obj RDAPObject) { p.printObject(obj, 0) } +// printObject dispatches obj to the print routine for its concrete RDAP type. func (p *Printer) printObject(obj RDAPObject, indentLevel uint) { if obj == nil { return @@ -96,6 +99,8 @@ func (p *Printer) printObject(obj RDAPObject, indentLevel uint) { } } +// printNameserverSearchResults prints a nameserver search result set: its +// conformance, notices, and matching nameservers. func (p *Printer) printNameserverSearchResults(sr *NameserverSearchResults, indentLevel uint) { p.printHeading("Nameserver Search Results", indentLevel) indentLevel++ @@ -119,6 +124,8 @@ func (p *Printer) printNameserverSearchResults(sr *NameserverSearchResults, inde p.printUnknowns(sr.DecodeData, indentLevel) } +// printEntitySearchResults prints an entity search result set: its +// conformance, notices, and matching entities. func (p *Printer) printEntitySearchResults(sr *EntitySearchResults, indentLevel uint) { p.printHeading("Entity Search Results", indentLevel) indentLevel++ @@ -142,6 +149,8 @@ func (p *Printer) printEntitySearchResults(sr *EntitySearchResults, indentLevel p.printUnknowns(sr.DecodeData, indentLevel) } +// printDomainSearchResults prints a domain search result set: its conformance, +// notices, and matching domains. func (p *Printer) printDomainSearchResults(sr *DomainSearchResults, indentLevel uint) { p.printHeading("Domain Search Results", indentLevel) indentLevel++ @@ -165,6 +174,7 @@ func (p *Printer) printDomainSearchResults(sr *DomainSearchResults, indentLevel p.printUnknowns(sr.DecodeData, indentLevel) } +// printError prints an RDAP error response: its code, title, and description. func (p *Printer) printError(e *Error, indentLevel uint) { p.printHeading("Error", indentLevel) indentLevel++ @@ -196,6 +206,7 @@ func (p *Printer) printError(e *Error, indentLevel uint) { p.printUnknowns(e.DecodeData, indentLevel) } +// printHelp prints a help response: its conformance and notices. func (p *Printer) printHelp(h *Help, indentLevel uint) { p.printHeading("Help", indentLevel) indentLevel++ @@ -215,6 +226,8 @@ func (p *Printer) printHelp(h *Help, indentLevel uint) { p.printUnknowns(h.DecodeData, indentLevel) } +// printDomain prints a domain object along with its nested entities, +// nameservers, and related fields. func (p *Printer) printDomain(d *Domain, indentLevel uint) { p.printHeading("Domain", indentLevel) indentLevel++ @@ -286,6 +299,7 @@ func (p *Printer) printDomain(d *Domain, indentLevel uint) { p.printUnknowns(d.DecodeData, indentLevel) } +// printAutnum prints an autnum (AS number) object and its related fields. func (p *Printer) printAutnum(a *Autnum, indentLevel uint) { p.printHeading("Autnum", indentLevel) @@ -353,6 +367,8 @@ func (p *Printer) printAutnum(a *Autnum, indentLevel uint) { p.printUnknowns(a.DecodeData, indentLevel) } +// printNameserver prints a nameserver object, including its IP addresses and +// related fields. func (p *Printer) printNameserver(n *Nameserver, indentLevel uint) { p.printHeading("Nameserver", indentLevel) @@ -409,6 +425,7 @@ func (p *Printer) printNameserver(n *Nameserver, indentLevel uint) { p.printUnknowns(n.DecodeData, indentLevel) } +// printIPAddressSet prints a nameserver's IPv4 and IPv6 addresses. func (p *Printer) printIPAddressSet(s *IPAddressSet, indentLevel uint) { p.printHeading("IP Addresses", indentLevel) @@ -425,6 +442,8 @@ func (p *Printer) printIPAddressSet(s *IPAddressSet, indentLevel uint) { p.printUnknowns(s.DecodeData, indentLevel) } +// printEntity prints an entity object, including its vCard, roles, and nested +// objects. func (p *Printer) printEntity(e *Entity, indentLevel uint) { p.printHeading("Entity", indentLevel) @@ -505,6 +524,7 @@ func (p *Printer) printEntity(e *Entity, indentLevel uint) { p.printUnknowns(e.DecodeData, indentLevel) } +// printIPNetwork prints an IP network object and its related fields. func (p *Printer) printIPNetwork(n *IPNetwork, indentLevel uint) { p.printHeading("IP Network", indentLevel) @@ -556,6 +576,7 @@ func (p *Printer) printIPNetwork(n *IPNetwork, indentLevel uint) { p.printUnknowns(n.DecodeData, indentLevel) } +// printPublicID prints a public identifier's type and value. func (p *Printer) printPublicID(pid PublicID, indentLevel uint) { p.printHeading("Public ID", indentLevel) @@ -567,6 +588,8 @@ func (p *Printer) printPublicID(pid PublicID, indentLevel uint) { p.printUnknowns(pid.DecodeData, indentLevel) } +// printSecureDNS prints a domain's DNSSEC information, including its DS and key +// records. func (p *Printer) printSecureDNS(s *SecureDNS, indentLevel uint) { p.printHeading("Secure DNS", indentLevel) @@ -601,6 +624,7 @@ func (p *Printer) printSecureDNS(s *SecureDNS, indentLevel uint) { p.printUnknowns(s.DecodeData, indentLevel) } +// printKeyData prints a DNSSEC key record. func (p *Printer) printKeyData(k KeyData, indentLevel uint) { p.printHeading("Key", indentLevel) @@ -639,6 +663,7 @@ func (p *Printer) printKeyData(k KeyData, indentLevel uint) { p.printUnknowns(k.DecodeData, indentLevel) } +// printDSData prints a DNSSEC delegation signer (DS) record. func (p *Printer) printDSData(d DSData, indentLevel uint) { p.printHeading("DSData", indentLevel) @@ -677,6 +702,7 @@ func (p *Printer) printDSData(d DSData, indentLevel uint) { p.printUnknowns(d.DecodeData, indentLevel) } +// printVariant prints a domain variant and its variant names. func (p *Printer) printVariant(v Variant, indentLevel uint) { p.printHeading("Variant", indentLevel) @@ -694,6 +720,7 @@ func (p *Printer) printVariant(v Variant, indentLevel uint) { p.printUnknowns(v.DecodeData, indentLevel) } +// printVariantName prints a single domain variant name. func (p *Printer) printVariantName(vn VariantName, indentLevel uint) { p.printHeading("Variant Name", indentLevel) @@ -704,6 +731,7 @@ func (p *Printer) printVariantName(vn VariantName, indentLevel uint) { p.printUnknowns(vn.DecodeData, indentLevel) } +// printRemark prints a remark: its title, type, description, and links. func (p *Printer) printRemark(r Remark, indentLevel uint) { p.printHeading("Remark", indentLevel) @@ -721,6 +749,7 @@ func (p *Printer) printRemark(r Remark, indentLevel uint) { p.printUnknowns(r.DecodeData, indentLevel) } +// printNotice prints a notice: its title, type, description, and links. func (p *Printer) printNotice(n Notice, indentLevel uint) { p.printHeading("Notice", indentLevel) @@ -738,6 +767,8 @@ func (p *Printer) printNotice(n Notice, indentLevel uint) { p.printUnknowns(n.DecodeData, indentLevel) } +// printLink formats and displays information about a `Link` instance, +// considering indentation and brief output settings. func (p *Printer) printLink(l Link, indent uint) { if p.BriefLinks { p.printValue("Link", l.Href, indent) @@ -761,12 +792,16 @@ func (p *Printer) printLink(l Link, indent uint) { p.printUnknowns(l.DecodeData, indent) } +// printHeading formats and prints a heading string with the +// specified indentation level. func (p *Printer) printHeading(heading string, indentLevel uint) { fmt.Fprintf(p.Writer, "%s%s:\n", strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)), p.cleanString(heading)) } +// printValue formats and prints a key-value pair with the specified +// indentation level to the output Writer. func (p *Printer) printValue(name string, value string, indentLevel uint) { if value == "" { return @@ -778,6 +813,8 @@ func (p *Printer) printValue(name string, value string, indentLevel uint) { p.cleanString(value)) } +// printEvent processes and prints details of an Event with +// configurable indentation and actor context display. func (p *Printer) printEvent(e Event, indentLevel uint, asEventActor bool) { if p.BriefOutput { return @@ -802,6 +839,8 @@ func (p *Printer) printEvent(e Event, indentLevel uint, asEventActor bool) { p.printUnknowns(e.DecodeData, indentLevel) } +// printUnknowns prints all unknown fields from the provided +// DecodeData object at the given indentation level. func (p *Printer) printUnknowns(d *DecodeData, indentLevel uint) { if d == nil { return @@ -817,6 +856,8 @@ func (p *Printer) printUnknowns(d *DecodeData, indentLevel uint) { } } +// printUnknown prints the key and value of an unknown field +// recursively, with formatting based on the value's type. func (p *Printer) printUnknown(key string, value any, indentLevel uint) { switch value.(type) { case bool: @@ -841,8 +882,10 @@ func (p *Printer) printUnknown(key string, value any, indentLevel uint) { } } +// cleanString returns str with output-breaking runes (newlines, carriage +// returns, and nulls) removed. func (p *Printer) cleanString(str string) string { - // Fast path: most RDAP values contain no bad runes, so skip the + // Most RDAP values contain no bad runes, so skip the // rune-by-rune strings.Map scan (and its allocation) entirely. if !strings.ContainsAny(str, "\n\r\x00") { return str @@ -851,6 +894,8 @@ func (p *Printer) cleanString(str string) string { return strings.Map(removeBadRunes, str) } +// removeBadRunes replaces unwanted runes ('\n', '\r', '\000') +// with -1; returns the rune otherwise. func removeBadRunes(r rune) rune { switch r { case '\n', '\r', '\000': diff --git a/response.go b/response.go index cd3ca04..83052ea 100644 --- a/response.go +++ b/response.go @@ -53,6 +53,9 @@ func newWhoisStyleResponse() *WhoisStyleResponse { return w } +// ToWhoisStyleResponse converts the response into an ordered set of WHOIS-style +// key/value fields. Only Domain responses are supported; others yield an empty +// result. func (r *Response) ToWhoisStyleResponse() *WhoisStyleResponse { w := newWhoisStyleResponse() diff --git a/sandbox/file.go b/sandbox/file.go index 4b1e16d..9453625 100644 --- a/sandbox/file.go +++ b/sandbox/file.go @@ -14,6 +14,8 @@ import ( var sandboxPath string +// LoadFile reads the named sandboxed file and returns its contents. It returns +// an error if the file is not one of the recognised sandbox files. func LoadFile(filename string) ([]byte, error) { if !IsFileInSandbox(filename) { return nil, errors.New("File not found in sandbox") @@ -45,6 +47,8 @@ func findPackagePath() string { return dir } +// IsFileInSandbox reports whether filename is one of the recognised sandbox +// files. func IsFileInSandbox(filename string) bool { switch filename { case "DigiCert_RDAP_Pilot_Client_Certificate.p12", diff --git a/test/file.go b/test/file.go index 79cff47..2359a6c 100644 --- a/test/file.go +++ b/test/file.go @@ -13,6 +13,8 @@ import ( var testDataPath string +// LoadFile reads the named file from the test data directory and returns its +// contents, panicking on error. func LoadFile(filename string) []byte { var body []byte diff --git a/test/http.go b/test/http.go index 76d46d7..573b309 100644 --- a/test/http.go +++ b/test/http.go @@ -33,6 +33,8 @@ type response struct { var responses map[TestDataset][]response var activatedURLs map[string]bool +// Start activates HTTP mocking and registers the responders for the given test +// dataset. It panics if two datasets register the same URL. func Start(set TestDataset) { httpmock.Activate() @@ -48,11 +50,14 @@ func Start(set TestDataset) { } } +// Finish deactivates HTTP mocking and clears the registered URLs, undoing +// Start. func Finish() { activatedURLs = make(map[string]bool) httpmock.DeactivateAndReset() } +// Get performs an HTTP GET and returns the response body, panicking on error. func Get(url string) []byte { resp, err := http.Get(url) if err != nil { diff --git a/test/http_test.go b/test/http_test.go index efee716..7327197 100644 --- a/test/http_test.go +++ b/test/http_test.go @@ -5,8 +5,8 @@ package test import ( - "testing" "strings" + "testing" ) func TestSmoke(t *testing.T) { From a63633df644652c2256ca3b978cce96148690897 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:40:14 +0100 Subject: [PATCH 05/13] chore: bump deps --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e60a44e..e45c9d0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/jarcoal/httpmock v1.4.1 github.com/mitchellh/go-homedir v1.1.0 - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 ) require ( diff --git a/go.sum b/go.sum index 30aa31b..c609fbb 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 226d6e939ad1426fec03a68f8083fb9540bedb07 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:50:32 +0100 Subject: [PATCH 06/13] fix: restore Answer field order, harden CI workflow permissions --- .github/workflows/build.yml | 5 +++++ bootstrap/answer.go | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72759df..080d811 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,9 @@ concurrency: cancel-in-progress: true group: ${{ github.workflow }}-${{ github.ref }} +permissions: + contents: read + jobs: build: name: Build @@ -17,6 +20,8 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@v4 + with: + persist-credentials: false - name: Build uses: actions/setup-go@v5 diff --git a/bootstrap/answer.go b/bootstrap/answer.go index 4075061..b4b2637 100644 --- a/bootstrap/answer.go +++ b/bootstrap/answer.go @@ -8,9 +8,6 @@ import "net/url" // Answer represents the result of bootstrapping a single query. type Answer struct { - // Matching service entry. Empty string if no match. - Entry string - // Query looked up in the registry. // // This includes any canonicalization performed to match the Service @@ -18,6 +15,9 @@ type Answer struct { // "AS" from AS numbers. Query string + // Matching service entry. Empty string if no match. + Entry string + // List of RDAP base URLs. URLs []*url.URL } From 6f1dfff63208c411d0d4892e20c366c5de446c90 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:31:27 +0100 Subject: [PATCH 07/13] chore: add CI lint/test workflows and resolve golangci-lint findings Add golangci-lint config (.golangci.yml) tuned for this library, plus Lint and Tests GitHub Actions workflows with tightened token scopes. Resolve all linter findings, including genuine fixes: - propagate context via http.NewRequestWithContext (noctx) - fix &*r.Server aliasing that mutated the shared Server URL (SA4001) - lowercase error strings and drop redundant "Error" prefixes (ST1005) - add explicit json tags to the bootstrap unmarshal struct (musttag) - replace test init() with explicit var initialization - tighten cache dir/file permissions to 0750/0600 - drop always-nil error returns from the decodeX helpers --- .github/workflows/lint.yml | 65 +++ .github/workflows/tests.yml | 41 ++ .golangci.yml | 496 ++++++++++++++++++++ benchmark_test.go | 12 +- bootstrap/asn_registry.go | 7 +- bootstrap/asn_registry_test.go | 3 +- bootstrap/cache/disk_cache.go | 15 +- bootstrap/cache/disk_cache_test.go | 33 +- bootstrap/cache/memory_cache.go | 3 +- bootstrap/cache/memory_cache_test.go | 8 +- bootstrap/client.go | 21 +- bootstrap/client_test.go | 2 - bootstrap/dns_registry.go | 3 +- bootstrap/dns_registry_test.go | 6 +- bootstrap/file.go | 14 +- bootstrap/file_test.go | 19 +- bootstrap/net_registry.go | 14 +- bootstrap/net_registry_test.go | 6 +- bootstrap/service_provider_registry.go | 3 +- bootstrap/service_provider_registry_test.go | 3 +- cli.go | 54 ++- client.go | 19 +- client_error.go | 4 +- client_test.go | 7 +- decoder.go | 80 ++-- decoder_test.go | 20 +- example_test.go | 2 +- print.go | 30 +- print_test.go | 12 +- request.go | 24 +- sandbox/file.go | 3 +- test/file.go | 1 - test/http.go | 69 +-- test/http_test.go | 3 +- vcard.go | 37 +- vcard_test.go | 2 +- 36 files changed, 854 insertions(+), 287 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .golangci.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ba2516b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,65 @@ +name: Lint +on: + pull_request: + paths: + - "**/*.go" + - "go.mod" + - "go.sum" + - ".golangci.yml" + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +# Deny all token scopes by default; the lint job escalates only what it needs. +permissions: {} + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the + # added or changed files to the repository. + contents: write + # Allow lint annotations in the PR. + pull-requests: read + + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: actions/setup-go@v5 + with: + go-version: '^1.26.0' + + - name: Download dependencies + run: go mod download + + - name: Install gofumpt + run: go install mvdan.cc/gofumpt@latest + + - name: Format code + run: gofumpt -w . + + - name: Run golangci-lint (fix) + uses: golangci/golangci-lint-action@v9 + with: + args: --fix --issues-exit-code=0 --timeout=5m + install-mode: goinstall + version: latest + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: apply linter fixes and formatting" + + - name: Run golangci-lint (check) + uses: golangci/golangci-lint-action@v9 + with: + args: --timeout=5m + install-mode: goinstall + version: latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3b303bb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests (Unit) +on: + pull_request: + paths: + - "**/*.go" + - "go.mod" + - "go.sum" + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +jobs: + tests-unit: + name: Tests (Unit) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '^1.26.0' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: | + go test -race -coverprofile=coverage.out ./... + coverage=$(go tool cover -func=coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}') + echo "Total coverage: $coverage%" + if (( $(echo "$coverage < 50" | bc -l) )); then + echo "Coverage $coverage% is below 50% threshold" + exit 1 + fi diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f185934 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,496 @@ +# This file is licensed under the terms of the MIT license https://opensource.org/license/mit +# Copyright (c) 2021-2025 Marat Reymers + +## Golden config for golangci-lint v2.3.1 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adapt it to suit your needs. +# If this config helps you, please consider keeping a link to this file (see the next comment). + +# Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 + +version: "2" + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + +formatters: + enable: + - gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible + - goimports # checks if the code and import statements are formatted according to the 'goimports' command + ## you may want to enable + #- gci # checks if code and import statements are formatted, with additional rules + #- gofmt # checks if the code is formatted according to 'gofmt' command + #- golines # splits long lines including func signatures — do not enable + #- swaggo # formats swaggo comments + +linters: + enable: + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - canonicalheader # checks whether net/http.Header uses canonical header + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - cyclop # checks function and package cyclomatic complexity + - decorder # checks declaration order and count of types, constants, variables and functions + - depguard # checks if package imports are in a list of acceptable packages + - durationcheck # checks for two durations multiplied together + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - exhaustive # checks exhaustiveness of enum switch statements + - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions + - fatcontext # detects nested contexts in loops + - forbidigo # forbids identifiers + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecknoinits # checks that no init functions are present in Go code + - gochecksumtype # checks exhaustiveness on Go "sum types" + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godot # checks if comments end in a period + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution + - ineffassign # detects when assignments to existing variables are not used + - intrange # finds places where for loops could make use of an integer range + - ireturn # accept interfaces, return concrete types + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - recvcheck # checks for receiver type consistency + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unqueryvet # detects "SELECT *" SQL queries + - unparam # reports unused function parameters + - unused # checks for unused constants, variables, functions and types + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - usetesting # reports uses of functions with replacement inside the testing package + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + - zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## you may want to enable + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- arangolint # opinionated best practices for arangodb client + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- gochecknoglobals # checks that no global variables exist + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- testpackage # makes you use a separate _test package + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + + ## disabled for this library (diverges from the upstream golden config) + #- gocognit # overlaps with gocyclo (still enabled); only borderline hits here + #- goconst # flagged literals are RDAP type/path names already centralized in single switches + #- embeddedstructfieldcheck # cosmetic field ordering churn on stable public structs + #- funcorder # pure constructor/method ordering; no functional value here + #- godox # the codebase tracks legitimate TODO/FIXME notes + #- mnd # overwhelmingly false positives: IP versions, HTTP status codes, vCard ADR offsets + #- wrapcheck # this library deliberately returns bare errors; wrapping would also change error text consumers may match on + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- err113 # [too strict] checks the errors handling expressions + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- lll # reports long lines without auto-splitting func signatures + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl_v5 # newline formatting + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled. + # Default: 0.0 + package-average: 15.0 + + depguard: + # Rules to apply. + # + # Variables: + # - File Variables + # Use an exclamation mark `!` to negate a variable. + # Example: `!$test` matches any file that is not a go test file. + # + # `$all` - matches all go files + # `$test` - matches all go test files + # + # - Package Variables + # + # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) + # + # Default (applies if no custom rules are defined): Only allow $gostd in all files. + rules: + "deprecated": + # List of file globs that will match this list of settings to compare against. + # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. + # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. + # The placeholder '${config-path}' is substituted with a path relative to the configuration file. + # Default: $all + files: + - "$all" + # List of packages that are not allowed. + # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). + # Default: [] + deny: + - pkg: github.com/golang/protobuf + desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - pkg: github.com/satori/go.uuid + desc: Use github.com/google/uuid instead, satori's package is not maintained + - pkg: github.com/gofrs/uuid$ + desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 + "non-test files": + files: + - "!$test" + deny: + - pkg: math/rand$ + desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 + + dupl: + # Tokens count to trigger issue. + # Default: 150 + threshold: 200 # Higher threshold = less sensitive. + + embeddedstructfieldcheck: + # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. + # Default: false + forbid-mutex: true + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Disabled: the reflection-based decoder and vCard parser use deliberate + # single-value assertions on values whose dynamic type is already known. + # Default: false + check-type-assertions: false + + exhaustive: + # A default case satisfies exhaustiveness. The decoder switches over + # reflect.Kind and handle only the relevant kinds with a default fallback; + # enumerating all 26 kinds would be pointless noise. + # Default: false + default-signifies-exhaustive: true + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to exclude struct packages and their names from checks. + # Regular expressions must match complete canonical struct package/name/structname. + # Default: [] + exclude: + # std libs + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ + # public libs + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + + funcorder: + # Checks if the exported methods of a structure are placed before the non-exported ones. + # Default: true + struct-method: false + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 120 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + + gochecksumtype: + # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. + # Default: true + default-signifies-exhaustive: false + + gocritic: + enabled-checks: + # Detects nil usages in http.NewRequest calls, suggesting http.NoBody as an alternative. + # https://go-critic.com/overview.html#httpnobody + - httpNoBody + # Detects "%s" formatting directives that can be replaced with %q. + # https://go-critic.com/overview.html#sprintfquotedstring + - sprintfQuotedString + # Detects issue in Query() and Exec() calls. + # https://go-critic.com/overview.html#sqlquery + - sqlQuery + # Detects unchecked errors in if statements. + # https://go-critic.com/overview.html#uncheckedinlineerr + - uncheckedInlineErr + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be found at https://go-critic.com/overview. + settings: + captLocal: + # Whether to restrict the checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + gocyclo: + # Minimal code complexity to report. + # Default: 30. + # We up this to 45, as it often results in arbitrarily breaking up longer functions, often without any benefit. + min-complexity: 45 + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. + # Default: [] + disable: + - fieldalignment # too strict + # Settings per analyzer. + settings: + shadow: + # Whether to be strict about shadowing; can be noisy. + # Default: false + strict: false + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + ireturn: + allow: + - error + - empty + - anon + - stdlib + - generic # Also allow generic type parameters. + # This library's own interfaces are returned by factory/strategy + # functions (e.g. newRegistry picks a Registry implementation by type), + # which is correct design. Still flags leaking third-party interfaces. + - github.com/openrdap/rdap + + mnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - args.Error + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nestif: + # Minimal complexity of if statements to report. + # Default: 5 + min-complexity: 10 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + reassign: + # Patterns for global variable names that are checked for reassignment. + # See https://github.com/curioswitch/go-reassign#usage + # Default: ["EOF", "Err.*"] + patterns: + - ".*" + + revive: + rules: + - name: context-keys-type + disabled: true # Allow basic types as context keys. + + rowserrcheck: + # database/sql is always checked. + # Default: [] + packages: + - github.com/jmoiron/sqlx + + sloglint: + # Enforce not using global loggers. + # Values: + # - "": disabled + # - "all": report all global loggers + # - "default": report only the default slog logger + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global + # Default: "" + no-global: all + # Enforce using methods that accept a context. + # Values: + # - "": disabled + # - "all": report all contextless calls + # - "scope": report only if a context exists in the scope of the outermost function + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only + # Default: "" + context: scope + + staticcheck: + # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks + # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] + # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] + checks: + - all + # Allow built-in types as context keys. + - -SA1029 + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Use consistent method receiver names. + # https://staticcheck.dev/docs/checks/#ST1016 + - -ST1016 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + + tagliatelle: + case: + rules: + json: goCamel # Use Go-style camelCase which preserves acronyms. + yaml: goCamel + xml: goCamel + bson: goCamel + avro: goCamel + mapstructure: goCamel + + usetesting: + # Enable/disable `os.TempDir()` detections. + # Default: false + os-temp-dir: true + + exclusions: + # Log a warning if an exclusion rule is unused. + # Default: false + warn-unused: false + # Predefined exclusion rules. + # Default: [] + presets: + - common-false-positives + - std-error-handling + # Excluding configuration per-path, per-linter, per-text and per-source. + rules: + # Exclude shadow warnings for 'err', 'exists', and 'ok' variables. + - text: 'shadow: declaration of "(err|exists|ok)" shadows declaration' + linters: [ govet ] + # cli.go is the CLI argument-dispatch entrypoint; branchy by nature. + - path: 'cli\.go' + linters: [ nestif ] + - source: 'TODO' + linters: [ godot ] + - text: 'should have a package comment' + linters: [ revive ] + - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' + linters: [ revive ] + - text: 'package comment should be of the form ".+"' + source: '// ?(nolint|TODO)' + linters: [ revive ] + - text: 'comment on exported \S+ \S+ should be of the form ".+"' + source: '// ?(nolint|TODO)' + linters: [ revive, staticcheck ] + - path: '_test\.go' + linters: + - bodyclose + - dupl + - errcheck + - forbidigo # fmt.Print is fine in test helpers + - funlen + - gochecknoinits # init() is used to register test responders + - gosec + - noctx + - reassign # tests toggle homedir.DisableCache \ No newline at end of file diff --git a/benchmark_test.go b/benchmark_test.go index 8adcd29..51e02f8 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -30,7 +30,7 @@ func BenchmarkDecodeDomain(b *testing.B) { b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for range b.N { if _, err := NewDecoder(blob).Decode(); err != nil { b.Fatal(err) } @@ -46,14 +46,14 @@ var ( func BenchmarkEscapePathClean(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = escapePath(benchEscapePathClean) } } func BenchmarkEscapePathDirty(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = escapePath(benchEscapePathDirty) } } @@ -77,7 +77,7 @@ var ( func BenchmarkCleanStringClean(b *testing.B) { p := &Printer{} b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { for _, s := range benchCleanStringInputs { _ = p.cleanString(s) } @@ -87,7 +87,7 @@ func BenchmarkCleanStringClean(b *testing.B) { func BenchmarkCleanStringDirty(b *testing.B) { p := &Printer{} b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = p.cleanString(benchCleanStringDirty) } } @@ -103,7 +103,7 @@ func BenchmarkVCardValues(b *testing.B) { } b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = p.Values() } } diff --git a/bootstrap/asn_registry.go b/bootstrap/asn_registry.go index 76e3bcf..3cb83c2 100644 --- a/bootstrap/asn_registry.go +++ b/bootstrap/asn_registry.go @@ -66,7 +66,7 @@ func NewASNRegistry(json []byte) (*ASNRegistry, error) { registry, err := NewFile(json) if err != nil { - return nil, fmt.Errorf("Error parsing ASN registry: %s\n", err) + return nil, fmt.Errorf("parsing ASN registry: %w", err) } a := make([]asnRange, 0, len(registry.Entries)) @@ -97,7 +97,6 @@ func NewASNRegistry(json []byte) (*ASNRegistry, error) { func (a *ASNRegistry) Lookup(question *Question) (*Answer, error) { var asn uint32 asn, err := parseASN(question.Query) - if err != nil { return nil, err } @@ -116,7 +115,7 @@ func (a *ASNRegistry) Lookup(question *Question) (*Answer, error) { return &Answer{ Entry: entry, - Query: fmt.Sprintf("%d", asn), + Query: strconv.FormatUint(uint64(asn), 10), URLs: urls, }, nil } @@ -145,7 +144,7 @@ func parseASNRange(asnRange string) (uint32, uint32, error) { asns := strings.Split(asnRange, "-") if len(asns) != 1 && len(asns) != 2 { - return 0, 0, errors.New("Malformed ASN range") + return 0, 0, errors.New("malformed ASN range") } minASN, err = strconv.ParseUint(asns[0], 10, 32) diff --git a/bootstrap/asn_registry_test.go b/bootstrap/asn_registry_test.go index d0c7c4f..a8860c5 100644 --- a/bootstrap/asn_registry_test.go +++ b/bootstrap/asn_registry_test.go @@ -14,11 +14,10 @@ func TestNetRegistryLookupsASN(t *testing.T) { test.Start(test.Bootstrap) defer test.Finish() - var bytes []byte = test.Get("https://data.iana.org/rdap/asn.json") + bytes := test.Get("https://data.iana.org/rdap/asn.json") var n *ASNRegistry n, err := NewASNRegistry(bytes) - if err != nil { t.Fatal(err) } diff --git a/bootstrap/cache/disk_cache.go b/bootstrap/cache/disk_cache.go index 9c66aba..d114f8a 100644 --- a/bootstrap/cache/disk_cache.go +++ b/bootstrap/cache/disk_cache.go @@ -74,11 +74,11 @@ func (d *DiskCache) InitDir() (bool, error) { return false, nil } - return false, errors.New("Cache dir is not a dir") + return false, errors.New("cache dir is not a dir") } if os.IsNotExist(err) { - if err = os.MkdirAll(d.Dir, 0775); err != nil { + if err = os.MkdirAll(d.Dir, 0o750); err != nil { return false, err } @@ -102,13 +102,13 @@ func (d *DiskCache) Save(filename string, data []byte) error { return err } - if err := os.WriteFile(d.cacheDirPath(filename), data, 0664); err != nil { + if err := os.WriteFile(d.cacheDirPath(filename), data, 0o600); err != nil { return err } fileModTime, err := d.modTime(filename) if err != nil { - return fmt.Errorf("File %s failed to save correctly: %s", filename, err) + return fmt.Errorf("file %s failed to save correctly: %w", filename, err) } d.lastLoadedModTime[filename] = fileModTime @@ -125,7 +125,7 @@ func (d *DiskCache) Save(filename string, data []byte) error { func (d *DiskCache) Load(filename string) ([]byte, error) { fileModTime, err := d.modTime(filename) if err != nil { - return nil, fmt.Errorf("Unable to load %s: %s", filename, err) + return nil, fmt.Errorf("unable to load %s: %w", filename, err) } var bytes []byte @@ -144,8 +144,8 @@ func (d *DiskCache) Load(filename string) ([]byte, error) { // // The returned state is one of: Absent, Good, ShouldReload, Expired. func (d *DiskCache) State(filename string) FileState { - var expiry = time.Now().Add(-d.Timeout) - var state = Absent + expiry := time.Now().Add(-d.Timeout) + state := Absent fileModTime, err := d.modTime(filename) if err == nil { @@ -167,7 +167,6 @@ func (d *DiskCache) State(filename string) FileState { func (d *DiskCache) modTime(filename string) (time.Time, error) { var fileInfo os.FileInfo fileInfo, err := os.Stat(d.cacheDirPath(filename)) - if err != nil { return time.Time{}, err } diff --git a/bootstrap/cache/disk_cache_test.go b/bootstrap/cache/disk_cache_test.go index b4e96a1..36ed8fe 100644 --- a/bootstrap/cache/disk_cache_test.go +++ b/bootstrap/cache/disk_cache_test.go @@ -6,7 +6,6 @@ package cache import ( "bytes" - "os" "path/filepath" "testing" "time" @@ -18,11 +17,7 @@ func TestNewDiskCacheDir(t *testing.T) { homedir.DisableCache = true defer func() { homedir.DisableCache = false }() - home, err := os.MkdirTemp("", "home") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(home) + home := t.TempDir() t.Setenv("HOME", home) @@ -47,11 +42,7 @@ func TestNewDiskCacheDir(t *testing.T) { } func TestDiskCache(t *testing.T) { - dir, err := os.MkdirTemp("", "test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) + dir := t.TempDir() rdapDir := filepath.Join(dir, ".openrdap") @@ -81,7 +72,13 @@ func TestDiskCache(t *testing.T) { } loaded1, err := m1.Load("asn.json") + if err != nil { + t.Fatalf("Load failed in m1: %s", err) + } loaded2, err := m2.Load("asn.json") + if err != nil { + t.Fatalf("Load failed in m2: %s", err) + } if m1.State("asn.json") != Good { t.Fatalf("asn.json expected good in m1") @@ -89,9 +86,9 @@ func TestDiskCache(t *testing.T) { t.Fatalf("asn.json expected good in m2") } - if bytes.Compare(loaded1, asn1) != 0 { + if !bytes.Equal(loaded1, asn1) { t.Fatalf("loaded1(%v) != asn1(%v)", loaded1, asn1) - } else if bytes.Compare(loaded2, asn1) != 0 { + } else if !bytes.Equal(loaded2, asn1) { t.Fatalf("loaded2(%v) != asn1(%v)", loaded2, asn1) } @@ -120,7 +117,13 @@ func TestDiskCache(t *testing.T) { m2.Timeout = time.Hour loaded1, err = m1.Load("asn.json") + if err != nil { + t.Fatalf("Load failed in m1: %s", err) + } loaded2, err = m2.Load("asn.json") + if err != nil { + t.Fatalf("Load failed in m2: %s", err) + } if m1.State("asn.json") != Good { t.Fatalf("asn.json expected good in m1") @@ -128,9 +131,9 @@ func TestDiskCache(t *testing.T) { t.Fatalf("asn.json expected good in m2") } - if bytes.Compare(loaded1, asn2) != 0 { + if !bytes.Equal(loaded1, asn2) { t.Fatalf("loaded1(%v) != asn2(%v)", loaded1, asn2) - } else if bytes.Compare(loaded2, asn2) != 0 { + } else if !bytes.Equal(loaded2, asn2) { t.Fatalf("loaded2(%v) != asn2(%v)", loaded2, asn2) } } diff --git a/bootstrap/cache/memory_cache.go b/bootstrap/cache/memory_cache.go index b8b2db2..cd13c6c 100644 --- a/bootstrap/cache/memory_cache.go +++ b/bootstrap/cache/memory_cache.go @@ -51,7 +51,7 @@ func (m *MemoryCache) Load(filename string) ([]byte, error) { data, ok := m.cache[filename] if !ok { - return nil, fmt.Errorf("File %s not in cache", filename) + return nil, fmt.Errorf("file %s not in cache", filename) } result := make([]byte, len(data)) @@ -77,5 +77,4 @@ func (m *MemoryCache) State(filename string) FileState { } return Good - } diff --git a/bootstrap/cache/memory_cache_test.go b/bootstrap/cache/memory_cache_test.go index 90db125..07cd590 100644 --- a/bootstrap/cache/memory_cache_test.go +++ b/bootstrap/cache/memory_cache_test.go @@ -19,23 +19,22 @@ func TestMemoryCache(t *testing.T) { var data []byte var err error - data, err = m.Load("not-in-cache.json") + _, err = m.Load("not-in-cache.json") if err == nil { t.Fatal("Load of not-in-cache.json unexpected result") } - var testData []byte = []byte("test") + testData := []byte("test") err = m.Save("file.json", testData) - if err != nil { t.Fatal("Save failed") } data, err = m.Load("file.json") - if len(data) == 0 || err != nil || bytes.Compare(data, testData) != 0 { + if len(data) == 0 || err != nil || !bytes.Equal(data, testData) { t.Fatal("Load of file.json unexpected result") } @@ -55,5 +54,4 @@ func TestMemoryCache(t *testing.T) { if m.State("file.json") != Expired { t.Fatal("m.State() returned non-Expired for expired file") } - } diff --git a/bootstrap/client.go b/bootstrap/client.go index 9fdf096..7fd7fa2 100644 --- a/bootstrap/client.go +++ b/bootstrap/client.go @@ -193,7 +193,6 @@ func (c *Client) DownloadWithContext(ctx context.Context, registry RegistryType) var s Registry json, s, err := c.download(ctx, registry) - if err != nil { return err } @@ -206,7 +205,6 @@ func (c *Client) DownloadWithContext(ctx context.Context, registry RegistryType) c.registries[registry] = s return nil - } func (c *Client) download(ctx context.Context, registry RegistryType) ([]byte, Registry, error) { @@ -222,12 +220,11 @@ func (c *Client) download(ctx context.Context, registry RegistryType) ([]byte, R baseURL.Path += "/" } - var fetchURL *url.URL = baseURL.ResolveReference(u) - req, err := http.NewRequest("GET", fetchURL.String(), nil) + fetchURL := baseURL.ResolveReference(u) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL.String(), http.NoBody) if err != nil { return nil, nil, err } - req = req.WithContext(ctx) resp, err := c.HTTP.Do(req) if err != nil { @@ -235,8 +232,8 @@ func (c *Client) download(ctx context.Context, registry RegistryType) ([]byte, R } defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, nil, fmt.Errorf("Server returned non-200 status code: %s", resp.Status) + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("server returned non-200 status code: %s", resp.Status) } json, err := io.ReadAll(resp.Body) @@ -246,7 +243,6 @@ func (c *Client) download(ctx context.Context, registry RegistryType) ([]byte, R var s Registry s, err = newRegistry(registry, json) - if err != nil { return json, nil, err } @@ -256,20 +252,19 @@ func (c *Client) download(ctx context.Context, registry RegistryType) ([]byte, R func (c *Client) freshenFromCache(registry RegistryType) { if c.Cache.State(c.filenameFor(registry)) == cache.ShouldReload { - c.reloadFromCache(registry) + // Best-effort refresh; on failure the existing in-memory registry is kept. + _ = c.reloadFromCache(registry) } } func (c *Client) reloadFromCache(registry RegistryType) error { json, err := c.Cache.Load(c.filenameFor(registry)) - if err != nil { return err } var s Registry s, err = newRegistry(registry, json) - if err != nil { return err } @@ -314,7 +309,7 @@ func (c *Client) Lookup(question *Question) (*Answer, error) { registry := question.RegistryType - var state cache.FileState = c.Cache.State(c.filenameFor(registry)) + state := c.Cache.State(c.filenameFor(registry)) c.Verbose(fmt.Sprintf(" bootstrap: Cache state: %s: %s", c.filenameFor(registry), state)) var forceDownload bool @@ -344,7 +339,7 @@ func (c *Client) Lookup(question *Question) (*Answer, error) { if answer.Entry != "" { c.Verbose(fmt.Sprintf(" bootstrap: Matching entry '%s'", answer.Entry)) } else { - c.Verbose(fmt.Sprintf(" bootstrap: No match")) + c.Verbose(" bootstrap: No match") } for i, url := range answer.URLs { diff --git a/bootstrap/client_test.go b/bootstrap/client_test.go index b767ce6..d86a6bd 100644 --- a/bootstrap/client_test.go +++ b/bootstrap/client_test.go @@ -17,7 +17,6 @@ func TestDownload(t *testing.T) { c := &Client{} err := c.Download(DNS) - if err != nil { t.Fatalf("Download() error: %s", err) } @@ -103,7 +102,6 @@ func TestLookups(t *testing.T) { continue } } - } } diff --git a/bootstrap/dns_registry.go b/bootstrap/dns_registry.go index 3dc660d..91646c3 100644 --- a/bootstrap/dns_registry.go +++ b/bootstrap/dns_registry.go @@ -23,9 +23,8 @@ type DNSRegistry struct { func NewDNSRegistry(json []byte) (*DNSRegistry, error) { var r *File r, err := NewFile(json) - if err != nil { - return nil, fmt.Errorf("Error parsing DNS bootstrap: %s", err) + return nil, fmt.Errorf("parsing DNS bootstrap: %w", err) } return &DNSRegistry{ diff --git a/bootstrap/dns_registry_test.go b/bootstrap/dns_registry_test.go index b2158e9..5f85ce7 100644 --- a/bootstrap/dns_registry_test.go +++ b/bootstrap/dns_registry_test.go @@ -14,11 +14,10 @@ func TestNetRegistryLookupsDNSNested(t *testing.T) { test.Start(test.BootstrapComplex) defer test.Finish() - var bytes []byte = test.Get("https://rdap.example.org/dns.json") + bytes := test.Get("https://rdap.example.org/dns.json") var d *DNSRegistry d, err := NewDNSRegistry(bytes) - if err != nil { t.Fatal(err) } @@ -63,11 +62,10 @@ func TestNetRegistryLookupsDNS(t *testing.T) { test.Start(test.Bootstrap) defer test.Finish() - var bytes []byte = test.Get("https://data.iana.org/rdap/dns.json") + bytes := test.Get("https://data.iana.org/rdap/dns.json") var d *DNSRegistry d, err := NewDNSRegistry(bytes) - if err != nil { t.Fatal(err) } diff --git a/bootstrap/file.go b/bootstrap/file.go index 5b20a1d..ff74dd3 100644 --- a/bootstrap/file.go +++ b/bootstrap/file.go @@ -31,15 +31,14 @@ type File struct { // NewFile constructs a File from a bootstrap registry file. func NewFile(jsonDocument []byte) (*File, error) { var doc struct { - Description string - Publication string - Version string + Description string `json:"description"` + Publication string `json:"publication"` + Version string `json:"version"` - Services [][][]string + Services [][][]string `json:"services"` } - err := json.Unmarshal(jsonDocument, &doc) - if err != nil { + if err := json.Unmarshal(jsonDocument, &doc); err != nil { return nil, err } @@ -65,14 +64,13 @@ func NewFile(jsonDocument []byte) (*File, error) { entries = s[1] rawURLs = s[2] default: - return nil, errors.New("Malformed bootstrap (bad services array)") + return nil, errors.New("malformed bootstrap (bad services array)") } var urls []*url.URL for _, rawURL := range rawURLs { url, err := url.Parse(rawURL) - // Ignore unparsable URLs. if err != nil { continue diff --git a/bootstrap/file_test.go b/bootstrap/file_test.go index e567e82..0f8ffda 100644 --- a/bootstrap/file_test.go +++ b/bootstrap/file_test.go @@ -14,11 +14,10 @@ func TestParseValid(t *testing.T) { test.Start(test.Bootstrap) defer test.Finish() - var bytes []byte = test.Get("https://data.iana.org/rdap/dns.json") + bytes := test.Get("https://data.iana.org/rdap/dns.json") var r *File r, err := NewFile(bytes) - if err != nil { t.Fatal(err) } @@ -32,7 +31,7 @@ func TestParseEmpty(t *testing.T) { test.Start(test.BootstrapMalformed) defer test.Finish() - var bytes []byte = test.Get("https://www.example.org/dns_empty.json") + bytes := test.Get("https://www.example.org/dns_empty.json") _, err := NewFile(bytes) @@ -45,7 +44,7 @@ func TestParseSyntaxError(t *testing.T) { test.Start(test.BootstrapMalformed) defer test.Finish() - var bytes []byte = test.Get("https://www.example.org/dns_syntax_error.json") + bytes := test.Get("https://www.example.org/dns_syntax_error.json") _, err := NewFile(bytes) @@ -58,7 +57,7 @@ func TestParseBadServices(t *testing.T) { test.Start(test.BootstrapMalformed) defer test.Finish() - var bytes []byte = test.Get("https://www.example.org/dns_bad_services.json") + bytes := test.Get("https://www.example.org/dns_bad_services.json") _, err := NewFile(bytes) @@ -71,17 +70,13 @@ func TestParseBadURL(t *testing.T) { test.Start(test.BootstrapMalformed) defer test.Finish() - var bytes []byte = test.Get("https://www.example.org/dns_bad_url.json") + bytes := test.Get("https://www.example.org/dns_bad_url.json") var r *File - r, err := NewFile(bytes) - - if err != nil { - t.Fatal(err) - } + r, err := NewFile(bytes) if err != nil { - t.Fatal("Unexpected error parsing file with bad URL") + t.Fatal("Unexpected error parsing file with bad URL: ", err) } if len(r.Entries) != 3 { diff --git a/bootstrap/net_registry.go b/bootstrap/net_registry.go index 3b636ee..f01f393 100644 --- a/bootstrap/net_registry.go +++ b/bootstrap/net_registry.go @@ -51,14 +51,13 @@ func (a netEntrySorter) Less(i int, j int) bool { // The document formats are specified in https://tools.ietf.org/html/rfc7484#section-5.1 and https://tools.ietf.org/html/rfc7484#section-5.2. func NewNetRegistry(json []byte, ipVersion int) (*NetRegistry, error) { if ipVersion != 4 && ipVersion != 6 { - return nil, fmt.Errorf("Unknown IP version %d", ipVersion) + return nil, fmt.Errorf("unknown IP version %d", ipVersion) } var registry *File registry, err := NewFile(json) - if err != nil { - return nil, fmt.Errorf("Error parsing net registry file: %s", err) + return nil, fmt.Errorf("parsing net registry file: %w", err) } n := &NetRegistry{ @@ -101,7 +100,6 @@ func (n *NetRegistry) Lookup(question *Question) (*Answer, error) { } _, lookupNet, err := net.ParseCIDR(input) - if err != nil { return nil, err } @@ -145,18 +143,18 @@ func (n *NetRegistry) Lookup(question *Question) (*Answer, error) { } func numIPBytesForVersion(ipVersion int) int { - len := 0 + var length int switch ipVersion { case 4: - len = net.IPv4len + length = net.IPv4len case 6: - len = net.IPv6len + length = net.IPv6len default: panic("Unknown IP version") } - return len + return length } // File returns a struct describing the registry's JSON document. diff --git a/bootstrap/net_registry_test.go b/bootstrap/net_registry_test.go index dd7ba7b..0f81f49 100644 --- a/bootstrap/net_registry_test.go +++ b/bootstrap/net_registry_test.go @@ -14,11 +14,10 @@ func TestNetRegistryLookupsIPv4(t *testing.T) { test.Start(test.Bootstrap) defer test.Finish() - var bytes []byte = test.Get("https://data.iana.org/rdap/ipv4.json") + bytes := test.Get("https://data.iana.org/rdap/ipv4.json") var n *NetRegistry n, err := NewNetRegistry(bytes, 4) - if err != nil { t.Fatal(err) } @@ -63,11 +62,10 @@ func TestNetRegistryLookupsIPv6(t *testing.T) { test.Start(test.Bootstrap) defer test.Finish() - var bytes []byte = test.Get("https://data.iana.org/rdap/ipv6.json") + bytes := test.Get("https://data.iana.org/rdap/ipv6.json") var n *NetRegistry n, err := NewNetRegistry(bytes, 6) - if err != nil { t.Fatal(err) } diff --git a/bootstrap/service_provider_registry.go b/bootstrap/service_provider_registry.go index 74077dc..1bccfd0 100644 --- a/bootstrap/service_provider_registry.go +++ b/bootstrap/service_provider_registry.go @@ -26,9 +26,8 @@ type ServiceProviderRegistry struct { func NewServiceProviderRegistry(json []byte) (*ServiceProviderRegistry, error) { var r *File r, err := NewFile(json) - if err != nil { - return nil, fmt.Errorf("Error parsing Service Provider bootstrap: %s", err) + return nil, fmt.Errorf("parsing Service Provider bootstrap: %w", err) } return &ServiceProviderRegistry{ diff --git a/bootstrap/service_provider_registry_test.go b/bootstrap/service_provider_registry_test.go index a3ddf9a..ac1bf8a 100644 --- a/bootstrap/service_provider_registry_test.go +++ b/bootstrap/service_provider_registry_test.go @@ -14,11 +14,10 @@ func TestServiceProviderRegistryLookups(t *testing.T) { test.Start(test.Bootstrap) defer test.Finish() - var bytes []byte = test.Get("https://data.iana.org/rdap/object-tags.json") + bytes := test.Get("https://data.iana.org/rdap/object-tags.json") var s *ServiceProviderRegistry s, err := NewServiceProviderRegistry(bytes) - if err != nil { t.Fatal(err) } diff --git a/cli.go b/cli.go index 9d62526..86c0ae9 100644 --- a/cli.go +++ b/cli.go @@ -22,7 +22,7 @@ import ( "golang.org/x/crypto/pkcs12" - kingpin "github.com/alecthomas/kingpin/v2" + "github.com/alecthomas/kingpin/v2" ) var ( @@ -115,6 +115,8 @@ type CLIOptions struct { // |options| specifies extra options. // // Returns the program exit code. +// +//nolint:gocyclo,cyclop // TODO: refactor. func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOptions) int { // For duration timer (in --verbose output). start := time.Now() @@ -251,7 +253,6 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption autnum := strings.ToUpper(queryText) autnum = strings.TrimPrefix(autnum, "AS") result, err := strconv.ParseUint(autnum, 10, 32) - if err != nil { printError(stderr, fmt.Sprintf("Invalid ASN '%s'", queryText)) return 1 @@ -307,7 +308,6 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption // Server URL specified (--server)? if *serverFlag != "" { serverURL, err := url.Parse(*serverFlag) - if err != nil { printError(stderr, fmt.Sprintf("--server error: %s", err)) return 1 @@ -322,8 +322,9 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption verbose(fmt.Sprintf("rdap: Using server '%s'", serverURL)) } - // Custom TLS config. - tlsConfig := &tls.Config{InsecureSkipVerify: *insecureFlag} + // Custom TLS config. The --insecure flag intentionally allows skipping + // certificate verification. + tlsConfig := &tls.Config{InsecureSkipVerify: *insecureFlag} //nolint:gosec // opt-in via --insecure flag bs := &bootstrap.Client{} @@ -339,7 +340,7 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption if !options.Sandbox { dc.Dir = *cacheDirFlag } else { - verbose(fmt.Sprintf("rdap: Ignored --cache-dir option (sandbox mode enabled)")) + verbose("rdap: Ignored --cache-dir option (sandbox mode enabled)") } } @@ -387,18 +388,18 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption var clientCert tls.Certificate if *clientCertFilename != "" || *clientKeyFilename != "" { - if *clientP12FilenameAndPassword != "" { - printError(stderr, fmt.Sprintf("rdap: Error: Can't use both --cert/--key and --p12 together")) + switch { + case *clientP12FilenameAndPassword != "": + printError(stderr, "rdap: Error: Can't use both --cert/--key and --p12 together") return 1 - } else if *clientCertFilename == "" || *clientKeyFilename == "" { - printError(stderr, fmt.Sprintf("rdap: Error: --cert and --key must be used together")) + case *clientCertFilename == "" || *clientKeyFilename == "": + printError(stderr, "rdap: Error: --cert and --key must be used together") return 1 - } else if options.Sandbox { - verbose(fmt.Sprintf("rdap: Ignored --cert and --key options (sandbox mode enabled)")) - } else { + case options.Sandbox: + verbose("rdap: Ignored --cert and --key options (sandbox mode enabled)") + default: var err error clientCert, err = tls.LoadX509KeyPair(*clientCertFilename, *clientKeyFilename) - if err != nil { printError(stderr, fmt.Sprintf("rdap: Error: cannot load client certificate/key: %s", err)) return 1 @@ -411,7 +412,7 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption } else if *clientP12FilenameAndPassword != "" { // Split the filename and optional password. // [0] is the filename, [1] is the optional password. - var p12FilenameAndPassword []string = strings.SplitAfterN(*clientP12FilenameAndPassword, ":", 2) + p12FilenameAndPassword := strings.SplitAfterN(*clientP12FilenameAndPassword, ":", 2) p12FilenameAndPassword[0] = strings.TrimSuffix(p12FilenameAndPassword[0], ":") // Use a blank password if none was specified. @@ -438,7 +439,6 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption // Convert P12 to PEM blocks. var blocks []*pem.Block blocks, err = pkcs12.ToPEM(p12, p12FilenameAndPassword[1]) - if err != nil { printError(stderr, fmt.Sprintf("rdap: Error: cannot read client certificate: %s", err)) return 1 @@ -451,7 +451,6 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption } clientCert, err = tls.X509KeyPair(pemData, pemData) - if err != nil { printError(stderr, fmt.Sprintf("rdap: Error: cannot read client certificate: %s", err)) return 1 @@ -485,7 +484,7 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption } if *insecureFlag { - verbose(fmt.Sprintf("rdap: SSL certificate validation disabled")) + verbose("rdap: SSL certificate validation disabled") } // Set the request timeout. @@ -513,7 +512,7 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption } // Output formatting. - if !(*outputFormatText || *outputFormatWhois || *outputFormatJSON || *outputFormatRaw) { + if !*outputFormatText && !*outputFormatWhois && !*outputFormatJSON && !*outputFormatRaw { *outputFormatText = true } @@ -535,8 +534,15 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption // Print the response, JSON pretty-printed? if *outputFormatJSON { var out bytes.Buffer - json.Indent(&out, resp.HTTP[0].Body, "", " ") - out.WriteTo(stdout) + if err := json.Indent(&out, resp.HTTP[0].Body, "", " "); err != nil { + printError(stderr, fmt.Sprintf("Error: response body is not valid JSON: %s", err)) + return 1 + } + + if _, err := out.WriteTo(stdout); err != nil { + printError(stderr, fmt.Sprintf("Error writing output: %s", err)) + return 1 + } } // Print WHOIS style response out? @@ -557,10 +563,10 @@ func RunCLI(args []string, stdout io.Writer, stderr io.Writer, options CLIOption func safePrint(v string) string { removeBadChars := func(r rune) rune { - switch { - case r == '\000': + switch r { + case '\000': return -1 - case r == '\n': + case '\n': return ' ' default: return r diff --git a/client.go b/client.go index 5fdf24b..9c422af 100644 --- a/client.go +++ b/client.go @@ -120,7 +120,7 @@ func (c *Client) Do(req *Request) (*Response, error) { } c.Verbose("") - c.Verbose(fmt.Sprintf("client: Running...")) + c.Verbose("client: Running...") c.Verbose(fmt.Sprintf("client: Request type : %s", req.Type)) c.Verbose(fmt.Sprintf("client: Request query : %s", req.Query)) @@ -134,7 +134,7 @@ func (c *Client) Do(req *Request) (*Response, error) { } else if req.Server == nil { c.Verbose("client: Request URL : TBD, bootstrap required") - var bootstrapType *bootstrap.RegistryType = bootstrapTypeFor(req) + bootstrapType := bootstrapTypeFor(req) if bootstrapType == nil { return nil, &ClientError{ @@ -225,10 +225,10 @@ func (c *Client) Do(req *Request) (*Response, error) { // Implement additional fetches here. return resp, nil - } else if hrr.StatusCode == 404 { + } else if hrr.StatusCode == http.StatusNotFound { return resp, &ClientError{ Type: ObjectDoesNotExist, - Text: fmt.Sprintf("RDAP server returned 404, object does not exist."), + Text: "RDAP server returned 404, object does not exist.", } } } @@ -249,8 +249,8 @@ func (c *Client) get(rdapReq *Request) *HTTPResponse { start := time.Now() - // Set up the HTTP request. - req, err := http.NewRequest("GET", httpResponse.URL, nil) + // Set up the HTTP request. The request context carries the timeout. + req, err := http.NewRequestWithContext(rdapReq.Context(), http.MethodGet, httpResponse.URL, http.NoBody) if err != nil { httpResponse.Error = err httpResponse.Duration = time.Since(start) @@ -265,9 +265,6 @@ func (c *Client) get(rdapReq *Request) *HTTPResponse { // HTTP Accept header. req.Header.Add("Accept", "application/rdap+json, application/json") - // Add context for timeout. - req = req.WithContext(rdapReq.Context()) - // Make the HTTP request. resp, err := c.HTTP.Do(req) httpResponse.Response = resp @@ -302,8 +299,8 @@ func (c *Client) QueryDomain(domain string) (*Domain, error) { return nil, err } - if domain, ok := resp.Object.(*Domain); ok { - return domain, nil + if dom, ok := resp.Object.(*Domain); ok { + return dom, nil } else if respError, ok := resp.Object.(*Error); ok { return nil, clientErrorFromRDAPError(respError) } diff --git a/client_error.go b/client_error.go index 85b10ee..10cce41 100644 --- a/client_error.go +++ b/client_error.go @@ -5,6 +5,7 @@ package rdap import ( + "errors" "fmt" "strings" ) @@ -34,7 +35,8 @@ func (c ClientError) Error() string { } func isClientError(t ClientErrorType, err error) bool { - if ce, ok := err.(*ClientError); ok { + ce := &ClientError{} + if errors.As(err, &ce) { if ce.Type == t { return true } diff --git a/client_test.go b/client_test.go index a6e8a0e..775a505 100644 --- a/client_test.go +++ b/client_test.go @@ -33,11 +33,12 @@ func TestClientQueryDomain(t *testing.T) { domain, err := client.QueryDomain("example.cz") - if err != nil { + switch { + case err != nil: t.Errorf("Unexpected error: %s", err) - } else if domain == nil { + case domain == nil: t.Errorf("Unexpected nil Domain") - } else if domain.LDHName != "example.cz" { + case domain.LDHName != "example.cz": t.Errorf("Unexpected LDHName %s", domain.LDHName) } } diff --git a/decoder.go b/decoder.go index d89fa78..bd8db9d 100644 --- a/decoder.go +++ b/decoder.go @@ -183,7 +183,6 @@ func (d *Decoder) decodeTopLevel(src map[string]any) (any, error) { _, err := d.decode("", src, result, nil) return result.Interface(), err - } // decode decodes the JSON structure |src| into the value |dst|. @@ -202,19 +201,19 @@ func (d *Decoder) decode(keyName string, src any, dst reflect.Value, decodeData // Choose and run the correct decoder for |dst|'s type. switch dst.Kind() { case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - success, err = d.decodeUint(keyName, src, dst, decodeData) + success = d.decodeUint(keyName, src, dst, decodeData) case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - success, err = d.decodeInt(keyName, src, dst, decodeData) + success = d.decodeInt(keyName, src, dst, decodeData) case reflect.Float64: - success, err = d.decodeFloat64(keyName, src, dst, decodeData) + success = d.decodeFloat64(keyName, src, dst, decodeData) case reflect.Bool: - success, err = d.decodeBool(keyName, src, dst, decodeData) + success = d.decodeBool(keyName, src, dst, decodeData) case reflect.Struct: success, err = d.decodeStruct(keyName, src, dst, decodeData) case reflect.Pointer: success, err = d.decodePtr(keyName, src, dst, decodeData) case reflect.String: - success, err = d.decodeString(keyName, src, dst, decodeData) + success = d.decodeString(keyName, src, dst, decodeData) case reflect.Slice: success, err = d.decodeSlice(keyName, src, dst, decodeData) case reflect.Map: @@ -250,7 +249,6 @@ func (d *Decoder) decodeSlice(keyName string, src any, dst reflect.Value, decode // Decode into the result value. success, err := d.decode(keyName, v, reflect.Indirect(vdst), decodeData) - if err != nil { return false, err } @@ -295,7 +293,6 @@ func (d *Decoder) decodeMap(keyName string, src any, dst reflect.Value, decodeDa // Decode into the result value. success, err := d.decode(keyName+":"+k, v, reflect.Indirect(vdst), decodeData) - if err != nil { return false, err } @@ -317,26 +314,25 @@ func (d *Decoder) decodeMap(keyName string, src any, dst reflect.Value, decodeDa // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeUint(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { - var err error +func (d *Decoder) decodeUint(keyName string, src any, dst reflect.Value, decodeData *DecodeData) bool { var result uint64 success := true - switch src.(type) { + switch src := src.(type) { case bool: - if src.(bool) { + if src { result = 1 } d.addDecodeNote(decodeData, keyName, "bool to uint conversion") case float64: - result = uint64(src.(float64)) + result = uint64(src) d.addDecodeNote(decodeData, keyName, "float64 to uint conversion") case string: var convError error - result, convError = strconv.ParseUint(src.(string), 10, 64) + result, convError = strconv.ParseUint(src, 10, 64) if convError != nil { result = 0 @@ -378,7 +374,7 @@ func (d *Decoder) decodeUint(keyName string, src any, dst reflect.Value, decodeD } } - return success, err + return success } // decodeInt decodes |src| into the int8/16/32/64 |dst|. @@ -387,25 +383,25 @@ func (d *Decoder) decodeUint(keyName string, src any, dst reflect.Value, decodeD // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeInt(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { +func (d *Decoder) decodeInt(keyName string, src any, dst reflect.Value, decodeData *DecodeData) bool { var result int64 success := true - switch src.(type) { + switch src := src.(type) { case bool: - if src.(bool) { + if src { result = 1 } d.addDecodeNote(decodeData, keyName, "bool to int conversion") case float64: - result = int64(src.(float64)) + result = int64(src) d.addDecodeNote(decodeData, keyName, "float64 to int conversion") case string: var convError error - result, convError = strconv.ParseInt(src.(string), 10, 64) + result, convError = strconv.ParseInt(src, 10, 64) if convError != nil { result = 0 success = false @@ -449,10 +445,9 @@ func (d *Decoder) decodeInt(keyName string, src any, dst reflect.Value, decodeDa } else { dst.SetInt(result) } - } - return success, nil + return success } // decodeFloat64 decodes |src| into the float64 |dst|. @@ -461,25 +456,24 @@ func (d *Decoder) decodeInt(keyName string, src any, dst reflect.Value, decodeDa // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeFloat64(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { - var err error +func (d *Decoder) decodeFloat64(keyName string, src any, dst reflect.Value, decodeData *DecodeData) bool { var result float64 success := true - switch src.(type) { + switch src := src.(type) { case bool: - if src.(bool) { + if src { result = 1.0 } d.addDecodeNote(decodeData, keyName, "bool to float64 conversion") case float64: - result = src.(float64) + result = src case string: var convError error - result, convError = strconv.ParseFloat(src.(string), 64) + result, convError = strconv.ParseFloat(src, 64) if convError != nil { result = 0.0 @@ -498,7 +492,7 @@ func (d *Decoder) decodeFloat64(keyName string, src any, dst reflect.Value, deco dst.SetFloat(result) - return success, err + return success } // decodeString decodes |src| into the string |dst|. @@ -507,21 +501,20 @@ func (d *Decoder) decodeFloat64(keyName string, src any, dst reflect.Value, deco // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeString(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { - var err error +func (d *Decoder) decodeString(keyName string, src any, dst reflect.Value, decodeData *DecodeData) bool { var result string success := true - switch src.(type) { + switch src := src.(type) { case bool: - result = strconv.FormatBool(src.(bool)) + result = strconv.FormatBool(src) d.addDecodeNote(decodeData, keyName, "bool to string conversion") case float64: - result = strconv.FormatFloat(src.(float64), 'f', -1, 64) + result = strconv.FormatFloat(src, 'f', -1, 64) d.addDecodeNote(decodeData, keyName, "float64 to string conversion") case string: - result = src.(string) + result = src case nil: result = "" d.addDecodeNote(decodeData, keyName, "null to empty string conversion") @@ -532,7 +525,7 @@ func (d *Decoder) decodeString(keyName string, src any, dst reflect.Value, decod dst.SetString(result) - return success, err + return success } // decodeBool decodes |src| into the bool |dst|. @@ -541,17 +534,16 @@ func (d *Decoder) decodeString(keyName string, src any, dst reflect.Value, decod // these. // // The parameters and return variables are as per decode(). -func (d *Decoder) decodeBool(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, error) { - var err error +func (d *Decoder) decodeBool(keyName string, src any, dst reflect.Value, decodeData *DecodeData) bool { var result bool success := true - switch src.(type) { + switch s := src.(type) { case bool: - result = src.(bool) + result = s case float64: - f := src.(float64) + f := s if f != 0 { result = true } @@ -560,7 +552,7 @@ func (d *Decoder) decodeBool(keyName string, src any, dst reflect.Value, decodeD case string: var convError error - result, convError = strconv.ParseBool(src.(string)) + result, convError = strconv.ParseBool(s) if convError != nil { d.addDecodeNote(decodeData, keyName, "error converting string to bool") result = false @@ -578,7 +570,7 @@ func (d *Decoder) decodeBool(keyName string, src any, dst reflect.Value, decodeD dst.SetBool(result) - return success, err + return success } // decodeStruct decodes |src| into the struct |dst|. @@ -651,7 +643,7 @@ func structPlanFor(t reflect.Type) *structPlan { var walk func(t reflect.Type, prefix []int) walk = func(t reflect.Type, prefix []int) { - for i := 0; i < t.NumField(); i++ { + for i := range t.NumField() { structField := t.Field(i) index := append(append([]int{}, prefix...), i) diff --git a/decoder_test.go b/decoder_test.go index 2fb8a4c..c6811c4 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -13,8 +13,7 @@ import ( ) func TestDecodeEmpty(t *testing.T) { - type Empty struct { - } + type Empty struct{} runDecodeAndCompareTest(t, &Empty{}, ` {} `, &Empty{}) @@ -47,15 +46,16 @@ func TestDecodeDecodeData(t *testing.T) { t.Errorf("Decode values bad %v", x) } - if x.DecodeData == nil { + switch { + case x.DecodeData == nil: t.Errorf("DecodeData not instantiated") - } else if len(x.DecodeData.Notes("sF")) != 1 { + case len(x.DecodeData.Notes("sF")) != 1: t.Errorf("DecodeData notes not added") - } else if len(x.DecodeData.Fields()) != 4 { + case len(x.DecodeData.Fields()) != 4: t.Errorf("DecodeData Fields() bad") - } else if len(x.DecodeData.UnknownFields()) != 1 { + case len(x.DecodeData.UnknownFields()) != 1: t.Errorf("DecodeData UnknownFields() bad") - } else if !reflect.DeepEqual(x.DecodeData.Value("unknown"), "value") { + case !reflect.DeepEqual(x.DecodeData.Value("unknown"), "value"): t.Errorf("DecodeData bad Value()") } } @@ -313,15 +313,14 @@ func TestDecodeString(t *testing.T) { func TestDecodeBug1(t *testing.T) { jsonBlob := test.LoadFile("rdap/rdap-pilot.verisignlabs.com/entity-1-VRSN") - d := NewDecoder([]byte(jsonBlob)) + d := NewDecoder(jsonBlob) result, err := d.Decode() t.Logf("%s %s\n", result, err) } func TestDecodeMismatchedTypes(t *testing.T) { - type C struct { - } + type C struct{} type XYZ struct { A []string @@ -347,7 +346,6 @@ func runDecode(t *testing.T, target any, jsonBlob string) (any, bool) { d.target = target result, err := d.Decode() - if err != nil { t.Errorf("While decoding '%s', got error: %s", jsonBlob, err) return result, false diff --git a/example_test.go b/example_test.go index ec3b324..aa0a675 100644 --- a/example_test.go +++ b/example_test.go @@ -11,7 +11,7 @@ import ( ) func Example() { - var jsonBlob = []byte(` + jsonBlob := []byte(` { "objectClassName": "domain", "rdapConformance": ["rdap_level_0"], diff --git a/print.go b/print.go index 417dc02..fae72be 100644 --- a/print.go +++ b/print.go @@ -671,7 +671,7 @@ func (p *Printer) printDSData(d DSData, indentLevel uint) { if d.KeyTag != nil { p.printValue("Key Tag", - strconv.FormatUint(uint64(*d.KeyTag), 10), + strconv.FormatUint(*d.KeyTag, 10), indentLevel) } @@ -792,11 +792,17 @@ func (p *Printer) printLink(l Link, indent uint) { p.printUnknowns(l.DecodeData, indent) } +// indent returns the indentation prefix for the given nesting level. +func (p *Printer) indent(indentLevel uint) string { + //nolint:gosec // indent depth is bounded by RDAP object nesting; no overflow risk. + return strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)) +} + // printHeading formats and prints a heading string with the // specified indentation level. func (p *Printer) printHeading(heading string, indentLevel uint) { fmt.Fprintf(p.Writer, "%s%s:\n", - strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)), + p.indent(indentLevel), p.cleanString(heading)) } @@ -808,7 +814,7 @@ func (p *Printer) printValue(name string, value string, indentLevel uint) { } fmt.Fprintf(p.Writer, "%s%s: %s\n", - strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)), + p.indent(indentLevel), p.cleanString(name), p.cleanString(value)) } @@ -847,10 +853,10 @@ func (p *Printer) printUnknowns(d *DecodeData, indentLevel uint) { } for k, v := range d.values { - isKnown, _ := d.isKnown[k] - isOverridden, _ := d.overrideKnownValue[k] + isKnown := d.isKnown[k] + isOverridden := d.overrideKnownValue[k] - if !(isKnown && !isOverridden) { + if !isKnown || isOverridden { p.printUnknown(k, v, indentLevel) } } @@ -859,22 +865,22 @@ func (p *Printer) printUnknowns(d *DecodeData, indentLevel uint) { // printUnknown prints the key and value of an unknown field // recursively, with formatting based on the value's type. func (p *Printer) printUnknown(key string, value any, indentLevel uint) { - switch value.(type) { + switch value := value.(type) { case bool: - p.printValue(key, strconv.FormatBool(value.(bool)), indentLevel) + p.printValue(key, strconv.FormatBool(value), indentLevel) case float64: - p.printValue(key, strconv.FormatFloat(value.(float64), 'f', -1, 64), indentLevel) + p.printValue(key, strconv.FormatFloat(value, 'f', -1, 64), indentLevel) case string: - p.printValue(key, value.(string), indentLevel) + p.printValue(key, value, indentLevel) case []any: - for _, value2 := range value.([]any) { + for _, value2 := range value { p.printUnknown(key, value2, indentLevel) } case map[string]any: p.printHeading(key, indentLevel) indentLevel++ - for key2, value2 := range value.(map[string]any) { + for key2, value2 := range value { p.printUnknown(key2, value2, indentLevel) } default: diff --git a/print_test.go b/print_test.go index e254fda..c7cfb45 100644 --- a/print_test.go +++ b/print_test.go @@ -5,6 +5,7 @@ package rdap import ( + "io" "testing" "github.com/openrdap/rdap/test" @@ -15,21 +16,18 @@ func TestPrintDomain(t *testing.T) { printer := &Printer{ BriefLinks: true, + Writer: io.Discard, } - _ = obj - _ = printer - //printer.Print(obj) + printer.Print(obj) } func loadObject(filename string) RDAPObject { - jsonBlob := test.LoadFile(filename) + d := NewDecoder(test.LoadFile(filename)) - d := NewDecoder([]byte(jsonBlob)) result, err := d.Decode() - if err != nil { - panic("Decode unexpectedly failed") + panic("Decode unexpectedly failed: " + err.Error()) } return result diff --git a/request.go b/request.go index 76d7508..7a60604 100644 --- a/request.go +++ b/request.go @@ -263,7 +263,7 @@ func (r *Request) URL() *url.URL { resultURL = new(url.URL) *resultURL = *r.Server } else { - tempURL := &*r.Server + tempURL := r.Server tempURL.RawQuery = "" tempURL.Fragment = "" tempURLString := tempURL.String() @@ -275,8 +275,8 @@ func (r *Request) URL() *url.URL { tempURLString += path var err error - resultURL, err = url.Parse(tempURLString) + resultURL, err = url.Parse(tempURLString) if err != nil { return nil } @@ -322,11 +322,12 @@ func (r *Request) WithServer(server *url.URL) *Request { } func escapePath(text string) string { - // Fast path: find the first byte that needs escaping. The common case - // (clean ASCII domains / IPs) finds none and returns the input unchanged - // with no allocation. + // Find the first byte that needs escaping. The common case (clean ASCII + // domains / IPs) finds none and returns the input unchanged with no + // allocation. + j := -1 - for i := 0; i < len(text); i++ { + for i := range len(text) { if shouldPathEscape(text[i]) { j = i break @@ -337,8 +338,9 @@ func escapePath(text string) string { return text } - // Worst case: every remaining byte expands to "%XX" (3 bytes). Sizing for - // it guarantees a single allocation with no re-growth; RDAP paths are short. + // Every remaining byte expands to "%XX" (3 bytes). Sizing for it + // guarantees a single allocation with no re-growth; RDAP paths are + // short. escaped := make([]byte, 0, j+(len(text)-j)*3) escaped = append(escaped, text[:j]...) @@ -384,7 +386,7 @@ func NewHelpRequest() *Request { func NewAutnumRequest(asn uint32) *Request { return &Request{ Type: AutnumRequest, - Query: fmt.Sprintf("%d", asn), + Query: strconv.FormatUint(uint64(asn), 10), } } @@ -481,8 +483,7 @@ func NewAutoRequest(queryText string) *Request { } // IP network? - _, ipNet, err := net.ParseCIDR(queryText) - if ipNet != nil { + if _, ipNet, err := net.ParseCIDR(queryText); err == nil { return NewIPNetRequest(ipNet) } @@ -505,7 +506,6 @@ func parseAutnum(autnum string) (uint32, error) { autnum = strings.ToUpper(autnum) autnum = strings.TrimPrefix(autnum, "AS") result, err := strconv.ParseUint(autnum, 10, 32) - if err != nil { return 0, err } diff --git a/sandbox/file.go b/sandbox/file.go index 9453625..0135ea1 100644 --- a/sandbox/file.go +++ b/sandbox/file.go @@ -18,7 +18,7 @@ var sandboxPath string // an error if the file is not one of the recognised sandbox files. func LoadFile(filename string) ([]byte, error) { if !IsFileInSandbox(filename) { - return nil, errors.New("File not found in sandbox") + return nil, errors.New("file not found in sandbox") } var body []byte @@ -28,7 +28,6 @@ func LoadFile(filename string) ([]byte, error) { } body, err := os.ReadFile(path.Join(sandboxPath, filename)) - if err != nil { log.Panic(err) } diff --git a/test/file.go b/test/file.go index 2359a6c..49cff1d 100644 --- a/test/file.go +++ b/test/file.go @@ -23,7 +23,6 @@ func LoadFile(filename string) []byte { } body, err := os.ReadFile(path.Join(testDataPath, filename)) - if err != nil { log.Panic(err) } diff --git a/test/http.go b/test/http.go index 573b309..37d2303 100644 --- a/test/http.go +++ b/test/http.go @@ -5,6 +5,7 @@ package test import ( + "context" "io" "log" "net/http" @@ -30,8 +31,10 @@ type response struct { Body string } -var responses map[TestDataset][]response -var activatedURLs map[string]bool +var ( + responses = buildResponses() + activatedURLs = map[string]bool{} +) // Start activates HTTP mocking and registers the responders for the given test // dataset. It panics if two datasets register the same URL. @@ -59,7 +62,12 @@ func Finish() { // Get performs an HTTP GET and returns the response body, panicking on error. func Get(url string) []byte { - resp, err := http.Get(url) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody) + if err != nil { + log.Panic(err) + } + + resp, err := http.DefaultClient.Do(req) if err != nil { log.Panic(err) } @@ -74,45 +82,42 @@ func Get(url string) []byte { return data } -func init() { - responses = make(map[TestDataset][]response) - activatedURLs = make(map[string]bool) +// buildResponses loads every test dataset's mock HTTP responses from testdata. +func buildResponses() map[TestDataset][]response { + r := map[TestDataset][]response{} - loadTestDatasets() -} + load := func(set TestDataset, status int, url, filename string) { + body := LoadFile(filename) + r[set] = append(r[set], response{status, url, string(body)}) + } -func loadTestDatasets() { // Valid snapshot of the IANA bootstrap files. - load(Bootstrap, 200, "https://data.iana.org/rdap/asn.json", "bootstrap/asn.json") - load(Bootstrap, 200, "https://data.iana.org/rdap/dns.json", "bootstrap/dns.json") - load(Bootstrap, 200, "https://data.iana.org/rdap/ipv4.json", "bootstrap/ipv4.json") - load(Bootstrap, 200, "https://data.iana.org/rdap/ipv6.json", "bootstrap/ipv6.json") - load(Bootstrap, 200, "https://data.iana.org/rdap/object-tags.json", "bootstrap/object-tags.json") + load(Bootstrap, http.StatusOK, "https://data.iana.org/rdap/asn.json", "bootstrap/asn.json") + load(Bootstrap, http.StatusOK, "https://data.iana.org/rdap/dns.json", "bootstrap/dns.json") + load(Bootstrap, http.StatusOK, "https://data.iana.org/rdap/ipv4.json", "bootstrap/ipv4.json") + load(Bootstrap, http.StatusOK, "https://data.iana.org/rdap/ipv6.json", "bootstrap/ipv6.json") + load(Bootstrap, http.StatusOK, "https://data.iana.org/rdap/object-tags.json", "bootstrap/object-tags.json") // Malformed bootstrap files. - load(BootstrapMalformed, 200, "https://www.example.org/dns_bad_services.json", "bootstrap_malformed/dns_bad_services.json") - load(BootstrapMalformed, 200, "https://www.example.org/dns_bad_url.json", "bootstrap_malformed/dns_bad_url.json") - load(BootstrapMalformed, 200, "https://www.example.org/dns_empty.json", "bootstrap_malformed/dns_empty.json") - load(BootstrapMalformed, 200, "https://www.example.org/dns_syntax_error.json", "bootstrap_malformed/dns_syntax_error.json") + load(BootstrapMalformed, http.StatusOK, "https://www.example.org/dns_bad_services.json", "bootstrap_malformed/dns_bad_services.json") + load(BootstrapMalformed, http.StatusOK, "https://www.example.org/dns_bad_url.json", "bootstrap_malformed/dns_bad_url.json") + load(BootstrapMalformed, http.StatusOK, "https://www.example.org/dns_empty.json", "bootstrap_malformed/dns_empty.json") + load(BootstrapMalformed, http.StatusOK, "https://www.example.org/dns_syntax_error.json", "bootstrap_malformed/dns_syntax_error.json") // Valid bootstrap files testing more features than yet used by IANA. - load(BootstrapComplex, 200, "https://rdap.example.org/dns.json", "bootstrap_complex/dns.json") + load(BootstrapComplex, http.StatusOK, "https://rdap.example.org/dns.json", "bootstrap_complex/dns.json") // Bootstrap HTTP errors. - load(BootstrapHTTPError, 404, "https://data.iana.org/rdap/asn.json", "bootstrap_http_error/404.html") - load(BootstrapHTTPError, 404, "https://data.iana.org/rdap/dns.json", "bootstrap_http_error/404.html") - load(BootstrapHTTPError, 404, "https://data.iana.org/rdap/ipv4.json", "bootstrap_http_error/404.html") - load(BootstrapHTTPError, 404, "https://data.iana.org/rdap/ipv6.json", "bootstrap_http_error/404.html") + load(BootstrapHTTPError, http.StatusNotFound, "https://data.iana.org/rdap/asn.json", "bootstrap_http_error/404.html") + load(BootstrapHTTPError, http.StatusNotFound, "https://data.iana.org/rdap/dns.json", "bootstrap_http_error/404.html") + load(BootstrapHTTPError, http.StatusNotFound, "https://data.iana.org/rdap/ipv4.json", "bootstrap_http_error/404.html") + load(BootstrapHTTPError, http.StatusNotFound, "https://data.iana.org/rdap/ipv6.json", "bootstrap_http_error/404.html") // RDAP responses. - load(Responses, 200, "https://rdap.nic.cz/domain/example.cz", "rdap/rdap.nic.cz/domain-example.cz.json") - load(Responses, 404, "https://rdap.nic.cz/domain/non-existent.cz", "misc/empty.html") - load(Responses, 200, "https://rdap.nic.cz/domain/wrong-response-type.cz", "rdap/rdap.nic.cz/nameserver-ns2.pipni.cz.json") - load(Responses, 200, "https://rdap.nic.cz/domain/malformed.cz", "misc/malformed.json") -} - -func load(set TestDataset, status int, url string, filename string) { - var body []byte = LoadFile(filename) + load(Responses, http.StatusOK, "https://rdap.nic.cz/domain/example.cz", "rdap/rdap.nic.cz/domain-example.cz.json") + load(Responses, http.StatusNotFound, "https://rdap.nic.cz/domain/non-existent.cz", "misc/empty.html") + load(Responses, http.StatusOK, "https://rdap.nic.cz/domain/wrong-response-type.cz", "rdap/rdap.nic.cz/nameserver-ns2.pipni.cz.json") + load(Responses, http.StatusOK, "https://rdap.nic.cz/domain/malformed.cz", "misc/malformed.json") - responses[set] = append(responses[set], response{status, url, string(body)}) + return r } diff --git a/test/http_test.go b/test/http_test.go index 7327197..e4ff493 100644 --- a/test/http_test.go +++ b/test/http_test.go @@ -13,8 +13,7 @@ func TestSmoke(t *testing.T) { Start(Bootstrap) defer Finish() - var bytes []byte - bytes = Get("https://data.iana.org/rdap/asn.json") + bytes := Get("https://data.iana.org/rdap/asn.json") if !strings.Contains(string(bytes), "ripe.net") { t.Fatalf("ASN doesn't contain ripe.net: %s\n", string(bytes)) diff --git a/vcard.go b/vcard.go index 96fd861..222c622 100644 --- a/vcard.go +++ b/vcard.go @@ -113,7 +113,6 @@ func (p *VCardProperty) appendValueStrings(v any, strings *[]string) { default: panic("Unknown type") } - } // String returns the vCard as a multiline human readable string. For example: @@ -160,7 +159,6 @@ func NewVCard(jsonBlob []byte) (*VCard, error) { func NewVCardWithOptions(jsonBlob []byte, options VCardOptions) (*VCard, error) { var top []any err := json.Unmarshal(jsonBlob, &top) - if err != nil { return nil, err } @@ -176,7 +174,7 @@ func newVCardImpl(src any, options VCardOptions) (*VCard, error) { if !ok || len(top) != 2 { return nil, vCardError("structure is not a jCard (expected len=2 top level array)") - } else if s, ok := top[0].(string); !(ok && s == "vcard") { + } else if s, ok := top[0].(string); !ok || s != "vcard" { return nil, vCardError("structure is not a jCard (missing 'vcard')") } @@ -194,7 +192,6 @@ func newVCardImpl(src any, options VCardOptions) (*VCard, error) { var p any for _, p = range top[1].([]any) { property, err := decodeVCardProperty(p) - if err != nil { if options.IgnoreInvalidProperties { continue @@ -210,10 +207,7 @@ func newVCardImpl(src any, options VCardOptions) (*VCard, error) { } func decodeVCardProperty(p any) (*VCardProperty, error) { - var a []any - var ok bool - a, ok = p.([]any) - + a, ok := p.([]any) if !ok { return nil, vCardError("jCard property was not an array") } else if len(a) < 4 { @@ -221,21 +215,16 @@ func decodeVCardProperty(p any) (*VCardProperty, error) { } name, ok := a[0].(string) - if !ok { return nil, vCardError("jCard property name invalid") } - var parameters map[string][]string - var err error - parameters, err = readParameters(a[1]) - + parameters, err := readParameters(a[1]) if err != nil { return nil, err } propertyType, ok := a[2].(string) - if !ok { return nil, vCardError("jCard property type invalid") } @@ -314,25 +303,25 @@ func readParameters(p any) (map[string][]string, error) { } func readValue(value any, depth int) (any, error) { - switch value := value.(type) { + switch val := value.(type) { case nil: + //nolint:nilnil // JSON null is a valid jCard value, not an error. return nil, nil case string: - return value, nil + return val, nil case bool: - return value, nil + return val, nil case float64: - return value, nil + return val, nil case []any: if depth == 3 { return "", vCardError("Structured value too deep") } - result := make([]any, 0, len(value)) + result := make([]any, 0, len(val)) - for _, v2 := range value { + for _, v2 := range val { v3, err := readValue(v2, depth+1) - if err != nil { return nil, err } @@ -368,7 +357,7 @@ func (v *VCard) POBox() string { return v.getFirstAddressField(0) } -// ExtendedAddress returns the "extended address", e.g. an apartment +// ExtendedAddress returns the "extended address", e.g., an apartment // or suite number. // // Returns empty string if no address is present. @@ -442,7 +431,7 @@ func (v *VCard) Tel() string { // Fax returns the VCard's first fax number. // -// Returns empty string if the VCard contains no fax number. +// Returns an empty string if the VCard contains no fax number. func (v *VCard) Fax() string { properties := v.Get("tel") @@ -468,7 +457,7 @@ func (v *VCard) Email() string { // Org returns the VCard's org. // -// Returns empty string if the VCard contains no organization. +// Returns empty string if the VCard contains no organization. func (v *VCard) Org() string { return v.getFirstPropertySingleString("org") } diff --git a/vcard_test.go b/vcard_test.go index a8a5686..59d0c13 100644 --- a/vcard_test.go +++ b/vcard_test.go @@ -96,7 +96,7 @@ func TestVCardExample(t *testing.T) { expectedTel0 := &VCardProperty{ Name: "tel", - Parameters: map[string][]string{"type": []string{"work", "voice"}, "pref": []string{"1"}}, + Parameters: map[string][]string{"type": {"work", "voice"}, "pref": {"1"}}, Type: "uri", Value: "tel:+1-418-656-9254;ext=102", } From 272ecf4f25f80c9bd2b46827191fd51379c7d599 Mon Sep 17 00:00:00 2001 From: wolveix <31854736+wolveix@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:35:10 +0000 Subject: [PATCH 08/13] chore: apply linter fixes and formatting --- print.go | 1 - request.go | 3 ++- request_test.go | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/print.go b/print.go index fae72be..7dc231c 100644 --- a/print.go +++ b/print.go @@ -794,7 +794,6 @@ func (p *Printer) printLink(l Link, indent uint) { // indent returns the indentation prefix for the given nesting level. func (p *Printer) indent(indentLevel uint) string { - //nolint:gosec // indent depth is bounded by RDAP object nesting; no overflow risk. return strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)) } diff --git a/request.go b/request.go index 7a60604..decad0b 100644 --- a/request.go +++ b/request.go @@ -350,7 +350,8 @@ func escapePath(text string) string { if !shouldPathEscape(b) { escaped = append(escaped, b) } else { - escaped = append(escaped, '%', + escaped = append( + escaped, '%', "0123456789ABCDEF"[b>>4], "0123456789ABCDEF"[b&0xF], ) diff --git a/request_test.go b/request_test.go index db0a6b8..9e14502 100644 --- a/request_test.go +++ b/request_test.go @@ -116,7 +116,8 @@ func TestNewRawRequest(t *testing.T) { actualURL := r.URL() if actualURL.String() != urlString { - t.Errorf("Raw query for %s got %s, expected %s\n", + t.Errorf( + "Raw query for %s got %s, expected %s\n", urlString, actualURL.String(), urlString, From 4cbf82c983082005513bd22fec51a3e04e97d996 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:29:40 +0100 Subject: [PATCH 09/13] test: add golden-file end-to-end tests for RunCLI Drive RunCLI for each output mode (text/json/whois/raw) plus version and error paths, comparing exit code, stdout, and stderr against committed golden files. Network-backed cases use an in-process fixture server via --server and an in-memory cache, so nothing touches the network or the real filesystem. Regenerate with `go test -run TestRunCLI -update`. Also sort unknown-field output in the printer (printUnknowns and the nested-map case of printUnknown) so CLI output is deterministic. Raises RunCLI coverage from 0% to ~48%. --- cli_test.go | 103 ++++++++++++++++++ print.go | 24 ++++- testdata/cli/domain-json.golden | 179 +++++++++++++++++++++++++++++++ testdata/cli/domain-raw.golden | 4 + testdata/cli/domain-text.golden | 82 ++++++++++++++ testdata/cli/domain-whois.golden | 13 +++ testdata/cli/no-args.golden | 72 +++++++++++++ testdata/cli/unknown-type.golden | 5 + testdata/cli/version.golden | 5 + 9 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 cli_test.go create mode 100644 testdata/cli/domain-json.golden create mode 100644 testdata/cli/domain-raw.golden create mode 100644 testdata/cli/domain-text.golden create mode 100644 testdata/cli/domain-whois.golden create mode 100644 testdata/cli/no-args.golden create mode 100644 testdata/cli/unknown-type.golden create mode 100644 testdata/cli/version.golden diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..09a5aa8 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,103 @@ +// OpenRDAP +// Copyright 2017 Tom Harwood +// MIT License, see the LICENSE file. + +package rdap + +import ( + "bytes" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/openrdap/rdap/test" +) + +// Run with `go test -run TestRunCLI -update ./...` to regenerate the golden +// files after an intentional output change. +var updateGolden = flag.Bool("update", false, "update CLI golden files") + +// fixtureServer serves the RDAP test fixtures over HTTP so the CLI can be +// driven end-to-end via --server without touching the network. +func fixtureServer(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/domain/example.cz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rdap+json") + _, _ = w.Write(test.LoadFile("rdap/rdap.nic.cz/domain-example.cz.json")) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv +} + +// TestRunCLIGolden drives RunCLI end-to-end for a range of commands and output +// modes, comparing exit code, stdout, and stderr against committed golden files. +// Network-backed cases point --server at an in-process fixture server and use an +// in-memory cache (--cache-dir "") so nothing touches the real filesystem. +func TestRunCLIGolden(t *testing.T) { + srv := fixtureServer(t) + + withServer := func(args ...string) []string { + return append(args, "--cache-dir", "", "--server", srv.URL) + } + + cases := []struct { + name string + args []string + }{ + {"domain-text", withServer("example.cz")}, + {"domain-json", withServer("--json", "example.cz")}, + {"domain-whois", withServer("--whois", "example.cz")}, + {"domain-raw", withServer("--raw", "example.cz")}, + {"version", []string{"--version"}}, + {"no-args", []string{}}, + {"unknown-type", []string{"--type", "bogus", "example.cz"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := RunCLI(tc.args, &stdout, &stderr, CLIOptions{}) + + got := fmt.Sprintf("exit: %d\n--- stdout ---\n%s\n--- stderr ---\n%s", + code, stdout.String(), stderr.String()) + + assertGolden(t, tc.name, got) + }) + } +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + + goldenPath := filepath.Join("testdata", "cli", name+".golden") + + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(goldenPath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenPath, []byte(got), 0o600); err != nil { + t.Fatal(err) + } + + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run `go test -run TestRunCLI -update` to create): %s", err) + } + + if got != string(want) { + t.Errorf("output mismatch for %s (run `go test -run TestRunCLI -update` to regenerate)\n--- got ---\n%s\n--- want ---\n%s", + name, got, want) + } +} diff --git a/print.go b/print.go index 7dc231c..7ea5df9 100644 --- a/print.go +++ b/print.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" ) @@ -794,6 +795,7 @@ func (p *Printer) printLink(l Link, indent uint) { // indent returns the indentation prefix for the given nesting level. func (p *Printer) indent(indentLevel uint) string { + //nolint:gosec // Indent depth is bounded by RDAP object nesting; no overflow risk. return strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)) } @@ -851,12 +853,19 @@ func (p *Printer) printUnknowns(d *DecodeData, indentLevel uint) { return } - for k, v := range d.values { + // Iterate in sorted key order so output is deterministic. + keys := make([]string, 0, len(d.values)) + for k := range d.values { + keys = append(keys, k) + } + slices.Sort(keys) + + for _, k := range keys { isKnown := d.isKnown[k] isOverridden := d.overrideKnownValue[k] if !isKnown || isOverridden { - p.printUnknown(k, v, indentLevel) + p.printUnknown(k, d.values[k], indentLevel) } } } @@ -879,8 +888,15 @@ func (p *Printer) printUnknown(key string, value any, indentLevel uint) { p.printHeading(key, indentLevel) indentLevel++ - for key2, value2 := range value { - p.printUnknown(key2, value2, indentLevel) + // Iterate in sorted key order so output is deterministic. + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + slices.Sort(keys) + + for _, key2 := range keys { + p.printUnknown(key2, value[key2], indentLevel) } default: p.printValue(key, "[unprintable value]", indentLevel) diff --git a/testdata/cli/domain-json.golden b/testdata/cli/domain-json.golden new file mode 100644 index 0000000..216dfc0 --- /dev/null +++ b/testdata/cli/domain-json.golden @@ -0,0 +1,179 @@ +exit: 0 +--- stdout --- +{ + "status": [ + "active" + ], + "fred_nsset": { + "nameservers": [ + { + "objectClassName": "nameserver", + "handle": "ns2.pipni.cz", + "links": [ + { + "href": "https://rdap.nic.cz/nameserver/ns2.pipni.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/nameserver/ns2.pipni.cz" + } + ], + "ldhName": "ns2.pipni.cz" + }, + { + "objectClassName": "nameserver", + "handle": "ns3.pipni.cz", + "links": [ + { + "href": "https://rdap.nic.cz/nameserver/ns3.pipni.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/nameserver/ns3.pipni.cz" + } + ], + "ldhName": "ns3.pipni.cz" + }, + { + "objectClassName": "nameserver", + "handle": "ns.pipni.cz", + "links": [ + { + "href": "https://rdap.nic.cz/nameserver/ns.pipni.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/nameserver/ns.pipni.cz" + } + ], + "ldhName": "ns.pipni.cz" + } + ], + "objectClassName": "fred_nsset", + "handle": "NSS:PIPNI:1", + "links": [ + { + "href": "https://rdap.nic.cz/fred_nsset/NSS:PIPNI:1", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/fred_nsset/NSS:PIPNI:1" + } + ] + }, + "handle": "example.cz", + "links": [ + { + "href": "https://rdap.nic.cz/domain/example.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/domain/example.cz" + } + ], + "port43": "whois.nic.cz", + "nameservers": [ + { + "objectClassName": "nameserver", + "handle": "ns2.pipni.cz", + "links": [ + { + "href": "https://rdap.nic.cz/nameserver/ns2.pipni.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/nameserver/ns2.pipni.cz" + } + ], + "ldhName": "ns2.pipni.cz" + }, + { + "objectClassName": "nameserver", + "handle": "ns3.pipni.cz", + "links": [ + { + "href": "https://rdap.nic.cz/nameserver/ns3.pipni.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/nameserver/ns3.pipni.cz" + } + ], + "ldhName": "ns3.pipni.cz" + }, + { + "objectClassName": "nameserver", + "handle": "ns.pipni.cz", + "links": [ + { + "href": "https://rdap.nic.cz/nameserver/ns.pipni.cz", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/nameserver/ns.pipni.cz" + } + ], + "ldhName": "ns.pipni.cz" + } + ], + "ldhName": "example.cz", + "entities": [ + { + "objectClassName": "entity", + "handle": "SB:EXAMPLE", + "links": [ + { + "href": "https://rdap.nic.cz/entity/SB:EXAMPLE", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/entity/SB:EXAMPLE" + } + ], + "roles": [ + "registrant" + ] + }, + { + "objectClassName": "entity", + "handle": "REG-INTERNET-CZ", + "roles": [ + "registrar" + ] + }, + { + "objectClassName": "entity", + "handle": "EXAMPLE", + "links": [ + { + "href": "https://rdap.nic.cz/entity/EXAMPLE", + "type": "application/rdap+json", + "rel": "self", + "value": "https://rdap.nic.cz/entity/EXAMPLE" + } + ], + "roles": [ + "administrative" + ] + } + ], + "rdapConformance": [ + "rdap_level_0", + "fred_version_0" + ], + "notices": [ + { + "description": [ + "(c) 2015 CZ.NIC, z.s.p.o.\n\nIntended use of supplied data and information\n\nData contained in the domain name register, as well as information supplied through public information services of CZ.NIC association, are appointed only for purposes connected with Internet network administration and operation, or for the purpose of legal or other similar proceedings, in process as regards a matter connected particularly with holding and using a concrete domain name.\n" + ], + "title": "Disclaimer" + } + ], + "objectClassName": "domain", + "events": [ + { + "eventAction": "registration", + "eventDate": "2004-08-30T22:55:00+00:00" + }, + { + "eventAction": "expiration", + "eventDate": "2019-08-30T12:00:00+00:00" + }, + { + "eventAction": "transfer", + "eventDate": "2007-01-25T02:05:00+00:00" + } + ] +} +--- stderr --- diff --git a/testdata/cli/domain-raw.golden b/testdata/cli/domain-raw.golden new file mode 100644 index 0000000..d9f36b2 --- /dev/null +++ b/testdata/cli/domain-raw.golden @@ -0,0 +1,4 @@ +exit: 0 +--- stdout --- +{"status": ["active"], "fred_nsset": {"nameservers": [{"objectClassName": "nameserver", "handle": "ns2.pipni.cz", "links": [{"href": "https://rdap.nic.cz/nameserver/ns2.pipni.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/nameserver/ns2.pipni.cz"}], "ldhName": "ns2.pipni.cz"}, {"objectClassName": "nameserver", "handle": "ns3.pipni.cz", "links": [{"href": "https://rdap.nic.cz/nameserver/ns3.pipni.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/nameserver/ns3.pipni.cz"}], "ldhName": "ns3.pipni.cz"}, {"objectClassName": "nameserver", "handle": "ns.pipni.cz", "links": [{"href": "https://rdap.nic.cz/nameserver/ns.pipni.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/nameserver/ns.pipni.cz"}], "ldhName": "ns.pipni.cz"}], "objectClassName": "fred_nsset", "handle": "NSS:PIPNI:1", "links": [{"href": "https://rdap.nic.cz/fred_nsset/NSS:PIPNI:1", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/fred_nsset/NSS:PIPNI:1"}]}, "handle": "example.cz", "links": [{"href": "https://rdap.nic.cz/domain/example.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/domain/example.cz"}], "port43": "whois.nic.cz", "nameservers": [{"objectClassName": "nameserver", "handle": "ns2.pipni.cz", "links": [{"href": "https://rdap.nic.cz/nameserver/ns2.pipni.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/nameserver/ns2.pipni.cz"}], "ldhName": "ns2.pipni.cz"}, {"objectClassName": "nameserver", "handle": "ns3.pipni.cz", "links": [{"href": "https://rdap.nic.cz/nameserver/ns3.pipni.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/nameserver/ns3.pipni.cz"}], "ldhName": "ns3.pipni.cz"}, {"objectClassName": "nameserver", "handle": "ns.pipni.cz", "links": [{"href": "https://rdap.nic.cz/nameserver/ns.pipni.cz", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/nameserver/ns.pipni.cz"}], "ldhName": "ns.pipni.cz"}], "ldhName": "example.cz", "entities": [{"objectClassName": "entity", "handle": "SB:EXAMPLE", "links": [{"href": "https://rdap.nic.cz/entity/SB:EXAMPLE", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/entity/SB:EXAMPLE"}], "roles": ["registrant"]}, {"objectClassName": "entity", "handle": "REG-INTERNET-CZ", "roles": ["registrar"]}, {"objectClassName": "entity", "handle": "EXAMPLE", "links": [{"href": "https://rdap.nic.cz/entity/EXAMPLE", "type": "application/rdap+json", "rel": "self", "value": "https://rdap.nic.cz/entity/EXAMPLE"}], "roles": ["administrative"]}], "rdapConformance": ["rdap_level_0", "fred_version_0"], "notices": [{"description": ["(c) 2015 CZ.NIC, z.s.p.o.\n\nIntended use of supplied data and information\n\nData contained in the domain name register, as well as information supplied through public information services of CZ.NIC association, are appointed only for purposes connected with Internet network administration and operation, or for the purpose of legal or other similar proceedings, in process as regards a matter connected particularly with holding and using a concrete domain name.\n"], "title": "Disclaimer"}], "objectClassName": "domain", "events": [{"eventAction": "registration", "eventDate": "2004-08-30T22:55:00+00:00"}, {"eventAction": "expiration", "eventDate": "2019-08-30T12:00:00+00:00"}, {"eventAction": "transfer", "eventDate": "2007-01-25T02:05:00+00:00"}]} +--- stderr --- diff --git a/testdata/cli/domain-text.golden b/testdata/cli/domain-text.golden new file mode 100644 index 0000000..85d55bc --- /dev/null +++ b/testdata/cli/domain-text.golden @@ -0,0 +1,82 @@ +exit: 0 +--- stdout --- +Domain: + Domain Name: example.cz + Handle: example.cz + Status: active + Port43: whois.nic.cz + Conformance: rdap_level_0 + Conformance: fred_version_0 + Notice: + Title: Disclaimer + Description: (c) 2015 CZ.NIC, z.s.p.o.Intended use of supplied data and informationData contained in the domain name register, as well as information supplied through public information services of CZ.NIC association, are appointed only for purposes connected with Internet network administration and operation, or for the purpose of legal or other similar proceedings, in process as regards a matter connected particularly with holding and using a concrete domain name. + Link: https://rdap.nic.cz/domain/example.cz + Event: + Action: registration + Date: 2004-08-30T22:55:00+00:00 + Event: + Action: expiration + Date: 2019-08-30T12:00:00+00:00 + Event: + Action: transfer + Date: 2007-01-25T02:05:00+00:00 + Entity: + Handle: SB:EXAMPLE + Link: https://rdap.nic.cz/entity/SB:EXAMPLE + Role: registrant + Entity: + Handle: REG-INTERNET-CZ + Role: registrar + Entity: + Handle: EXAMPLE + Link: https://rdap.nic.cz/entity/EXAMPLE + Role: administrative + Nameserver: + Nameserver: ns2.pipni.cz + Handle: ns2.pipni.cz + Link: https://rdap.nic.cz/nameserver/ns2.pipni.cz + Nameserver: + Nameserver: ns3.pipni.cz + Handle: ns3.pipni.cz + Link: https://rdap.nic.cz/nameserver/ns3.pipni.cz + Nameserver: + Nameserver: ns.pipni.cz + Handle: ns.pipni.cz + Link: https://rdap.nic.cz/nameserver/ns.pipni.cz + fred_nsset: + handle: NSS:PIPNI:1 + links: + href: https://rdap.nic.cz/fred_nsset/NSS:PIPNI:1 + rel: self + type: application/rdap+json + value: https://rdap.nic.cz/fred_nsset/NSS:PIPNI:1 + nameservers: + handle: ns2.pipni.cz + ldhName: ns2.pipni.cz + links: + href: https://rdap.nic.cz/nameserver/ns2.pipni.cz + rel: self + type: application/rdap+json + value: https://rdap.nic.cz/nameserver/ns2.pipni.cz + objectClassName: nameserver + nameservers: + handle: ns3.pipni.cz + ldhName: ns3.pipni.cz + links: + href: https://rdap.nic.cz/nameserver/ns3.pipni.cz + rel: self + type: application/rdap+json + value: https://rdap.nic.cz/nameserver/ns3.pipni.cz + objectClassName: nameserver + nameservers: + handle: ns.pipni.cz + ldhName: ns.pipni.cz + links: + href: https://rdap.nic.cz/nameserver/ns.pipni.cz + rel: self + type: application/rdap+json + value: https://rdap.nic.cz/nameserver/ns.pipni.cz + objectClassName: nameserver + objectClassName: fred_nsset + +--- stderr --- diff --git a/testdata/cli/domain-whois.golden b/testdata/cli/domain-whois.golden new file mode 100644 index 0000000..a38cea0 --- /dev/null +++ b/testdata/cli/domain-whois.golden @@ -0,0 +1,13 @@ +exit: 0 +--- stdout --- +Domain Name: example.cz +Handle: example.cz +Registrar WHOIS Server: whois.nic.cz +Creation Date: 2004-08-30T22:55:00+00:00 +Expiration Date: 2019-08-30T12:00:00+00:00 +Domain Status: active +Name Server: ns2.pipni.cz +Name Server: ns3.pipni.cz +Name Server: ns.pipni.cz + +--- stderr --- diff --git a/testdata/cli/no-args.golden b/testdata/cli/no-args.golden new file mode 100644 index 0000000..2755af7 --- /dev/null +++ b/testdata/cli/no-args.golden @@ -0,0 +1,72 @@ +exit: 1 +--- stdout --- + +--- stderr --- +# Error: Query object required, e.g. rdap example.cz + +OpenRDAP v0.9.1 +(www.openrdap.org) + +Usage: rdap [OPTIONS] DOMAIN|IP|ASN|ENTITY|NAMESERVER|RDAP-URL + e.g. rdap example.com + rdap 192.0.2.0 + rdap 2001:db8:: + rdap AS2856 + rdap OPS4-RIPE + rdap https://rdap.nic.cz/domain/example.cz + + rdap --json https://rdap.nic.cz/domain/example.cz + rdap -s https://rdap.nic.cz -t help + +Options: + -h, --help Show help message. + -V, --version Print version and quit. + -v, --verbose Print verbose messages on STDERR. + + -T, --timeout=SECS Timeout after SECS seconds (default: 30). + -k, --insecure Disable SSL certificate verification. + +Output Options: + --text Output RDAP, plain text "tree" format (default). + -w, --whois Output WHOIS style (domain queries only). + -j, --json Output JSON, pretty-printed format. + -r, --raw Output the raw server response. + +Advanced options (query): + -s --server=URL RDAP server to query. + -t --type=TYPE RDAP query type. Normally auto-detected. The types are: + - ip + - domain + - autnum + - nameserver + - entity + - help + - url + - domain-search + - domain-search-by-nameserver + - domain-search-by-nameserver-ip + - nameserver-search + - nameserver-search-by-ip + - entity-search + - entity-search-by-handle + - autnum-search + The servers for domain, ip, autnum, url queries can be + determined automatically. Otherwise, the RDAP server + (--server=URL) must be specified. + +Advanced options (bootstrapping): + --cache-dir=DIR Bootstrap cache directory to use. Specify empty string + to disable bootstrap caching. The directory is created + automatically as needed. + (default: $XDG_CACHE_HOME/openrdap, falling back to + $HOME/.cache/openrdap). + --bs-url=URL Bootstrap service URL (default: https://data.iana.org/rdap) + --bs-ttl=SECS Bootstrap cache time in seconds (default: 3600) + +Advanced options (authentication): + -P, --p12=cert.p12[:password] Use client certificate & private key (PKCS#12 format) +or: + -C, --cert=cert.pem Use client certificate (PEM format) + -K, --key=cert.key Use client private key (PEM format) + + diff --git a/testdata/cli/unknown-type.golden b/testdata/cli/unknown-type.golden new file mode 100644 index 0000000..12d3491 --- /dev/null +++ b/testdata/cli/unknown-type.golden @@ -0,0 +1,5 @@ +exit: 1 +--- stdout --- + +--- stderr --- +# Unknown query type 'bogus' diff --git a/testdata/cli/version.golden b/testdata/cli/version.golden new file mode 100644 index 0000000..41a36d2 --- /dev/null +++ b/testdata/cli/version.golden @@ -0,0 +1,5 @@ +exit: 0 +--- stdout --- +OpenRDAP v0.9.1 + +--- stderr --- From 43c9f6479eb3ec950f74894afb450b18cfddfee0 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:29:41 +0100 Subject: [PATCH 10/13] ci: run unit tests on a Go 1.25.x and 1.26.x matrix go.mod declares a 1.25.0 floor while CI ran only 1.26, which could mask bugs that surface on the declared minimum. Test on both the floor and the latest release; lint continues on the latest toolchain only. --- .github/workflows/tests.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b303bb..2c7ee3f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,13 @@ jobs: tests-unit: name: Tests (Unit) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 1.25.x is the floor declared in go.mod; 1.26.x is the latest release. + # Testing both ensures the declared minimum stays buildable while the + # latest toolchain is exercised. + go-version: ['1.25.x', '1.26.x'] steps: - name: Checkout @@ -25,7 +32,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '^1.26.0' + go-version: ${{ matrix.go-version }} - name: Download dependencies run: go mod download From b6c73320a89dc1a8d7eac5c42438f8626fe9601f Mon Sep 17 00:00:00 2001 From: wolveix <31854736+wolveix@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:40:31 +0000 Subject: [PATCH 11/13] chore: apply linter fixes and formatting --- print.go | 1 - 1 file changed, 1 deletion(-) diff --git a/print.go b/print.go index 7ea5df9..a8ba8fc 100644 --- a/print.go +++ b/print.go @@ -795,7 +795,6 @@ func (p *Printer) printLink(l Link, indent uint) { // indent returns the indentation prefix for the given nesting level. func (p *Printer) indent(indentLevel uint) string { - //nolint:gosec // Indent depth is bounded by RDAP object nesting; no overflow risk. return strings.Repeat(string(p.IndentChar), int(indentLevel*p.IndentSize)) } From 053ee55e6e6784afa0b3427ad45a367f84e57dc1 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:14:36 +0100 Subject: [PATCH 12/13] test: colocate benchmarks with the code they exercise Distribute the benchmarks from benchmark_test.go into the _test.go file of the package code each one exercises, matching this repo's per-file test layout (idiomatic Go; the standard library colocates benchmarks too): BenchmarkDecodeDomain -> decoder_test.go BenchmarkEscapePathClean/Dirty -> request_test.go BenchmarkCleanStringClean/Dirty -> print_test.go BenchmarkVCardValues -> vcard_test.go BenchmarkDecodeDomain now loads its fixture via test.LoadFile for consistency (outside the timed loop, so results are unaffected). --- benchmark_test.go | 109 ---------------------------------------------- decoder_test.go | 17 ++++++++ print_test.go | 34 +++++++++++++++ request_test.go | 21 +++++++++ vcard_test.go | 16 +++++++ 5 files changed, 88 insertions(+), 109 deletions(-) delete mode 100644 benchmark_test.go diff --git a/benchmark_test.go b/benchmark_test.go deleted file mode 100644 index 51e02f8..0000000 --- a/benchmark_test.go +++ /dev/null @@ -1,109 +0,0 @@ -// OpenRDAP -// Copyright 2017 Tom Harwood -// MIT License, see the LICENSE file. - -// Benchmarks for hot-path string handling and the reflection-based decoder. -// These exist as regression guards for the optimizations in escapePath, -// Printer.cleanString, VCardProperty.Values, and the chooseFields type-plan -// cache. -// -// Run with: -// -// go test -run '^$' -bench . -benchmem ./... -package rdap - -import ( - "os" - "testing" -) - -// BenchmarkDecodeDomain decodes a realistic nested domain response (entities, -// nameservers, links). It is the headline benchmark for the chooseFields -// type-plan cache, which resolves a struct type's decodable fields once instead -// of on every decode. -func BenchmarkDecodeDomain(b *testing.B) { - blob, err := os.ReadFile("test/testdata/rdap/rdap.nic.cz/domain-example.cz.json") - if err != nil { - b.Fatal(err) - } - - b.ReportAllocs() - b.ResetTimer() - - for range b.N { - if _, err := NewDecoder(blob).Decode(); err != nil { - b.Fatal(err) - } - } -} - -// escapePath runs on every request path. The clean case (no byte needs -// escaping) is overwhelmingly common and should not allocate. -var ( - benchEscapePathClean = "rdap.example.com" - benchEscapePathDirty = "xn--n3h.example/path with spaces & symbols" -) - -func BenchmarkEscapePathClean(b *testing.B) { - b.ReportAllocs() - for range b.N { - _ = escapePath(benchEscapePathClean) - } -} - -func BenchmarkEscapePathDirty(b *testing.B) { - b.ReportAllocs() - for range b.N { - _ = escapePath(benchEscapePathDirty) - } -} - -// cleanString runs on every printed heading and value. The clean case (no bad -// runes) is the common case and should avoid the rune-by-rune strings.Map scan. -var ( - benchCleanStringInputs = []string{ - "Domain Name", - "EXAMPLE.COM", - "2021-01-01T00:00:00Z", - "client transfer prohibited", - "https://rdap.verisign.com/com/v1/domain/EXAMPLE.COM", - "ns1.example.com", - "registrant", - "Registrar Abuse Contact Email", - } - benchCleanStringDirty = "line one\nline two\r\x00trailing" -) - -func BenchmarkCleanStringClean(b *testing.B) { - p := &Printer{} - b.ReportAllocs() - for range b.N { - for _, s := range benchCleanStringInputs { - _ = p.cleanString(s) - } - } -} - -func BenchmarkCleanStringDirty(b *testing.B) { - p := &Printer{} - b.ReportAllocs() - for range b.N { - _ = p.cleanString(benchCleanStringDirty) - } -} - -// BenchmarkVCardValues guards the flattening cost of VCardProperty.Values, -// which Tel/Fax now call once per property rather than twice. -func BenchmarkVCardValues(b *testing.B) { - p := &VCardProperty{ - Name: "tel", - Type: "uri", - Parameters: map[string][]string{"type": {"voice"}}, - Value: "tel:+1.5551234567", - } - - b.ReportAllocs() - for range b.N { - _ = p.Values() - } -} diff --git a/decoder_test.go b/decoder_test.go index c6811c4..fa3af54 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -368,3 +368,20 @@ func runDecodeAndCompareTest(t *testing.T, target any, jsonBlob string, expected spew.Sdump(result)) } } + +// BenchmarkDecodeDomain decodes a realistic nested domain response (entities, +// nameservers, links). It is the headline benchmark for the chooseFields +// type-plan cache, which resolves a struct type's decodable fields once instead +// of on every decode. +func BenchmarkDecodeDomain(b *testing.B) { + blob := test.LoadFile("rdap/rdap.nic.cz/domain-example.cz.json") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + if _, err := NewDecoder(blob).Decode(); err != nil { + b.Fatal(err) + } + } +} diff --git a/print_test.go b/print_test.go index c7cfb45..35db61d 100644 --- a/print_test.go +++ b/print_test.go @@ -32,3 +32,37 @@ func loadObject(filename string) RDAPObject { return result } + +// cleanString runs on every printed heading and value. The clean case (no bad +// runes) is the common case and should avoid the rune-by-rune strings.Map scan. +var ( + benchCleanStringInputs = []string{ + "Domain Name", + "EXAMPLE.COM", + "2021-01-01T00:00:00Z", + "client transfer prohibited", + "https://rdap.verisign.com/com/v1/domain/EXAMPLE.COM", + "ns1.example.com", + "registrant", + "Registrar Abuse Contact Email", + } + benchCleanStringDirty = "line one\nline two\r\x00trailing" +) + +func BenchmarkCleanStringClean(b *testing.B) { + p := &Printer{} + b.ReportAllocs() + for range b.N { + for _, s := range benchCleanStringInputs { + _ = p.cleanString(s) + } + } +} + +func BenchmarkCleanStringDirty(b *testing.B) { + p := &Printer{} + b.ReportAllocs() + for range b.N { + _ = p.cleanString(benchCleanStringDirty) + } +} diff --git a/request_test.go b/request_test.go index 9e14502..9d7fc05 100644 --- a/request_test.go +++ b/request_test.go @@ -244,3 +244,24 @@ func TestNewAutoRequest(t *testing.T) { } } } + +// escapePath runs on every request path. The clean case (no byte needs +// escaping) is overwhelmingly common and should not allocate. +var ( + benchEscapePathClean = "rdap.example.com" + benchEscapePathDirty = "xn--n3h.example/path with spaces & symbols" +) + +func BenchmarkEscapePathClean(b *testing.B) { + b.ReportAllocs() + for range b.N { + _ = escapePath(benchEscapePathClean) + } +} + +func BenchmarkEscapePathDirty(b *testing.B) { + b.ReportAllocs() + for range b.N { + _ = escapePath(benchEscapePathDirty) + } +} diff --git a/vcard_test.go b/vcard_test.go index 59d0c13..e27ff1a 100644 --- a/vcard_test.go +++ b/vcard_test.go @@ -179,3 +179,19 @@ func TestVCardQuickAccessors(t *testing.T) { t.Errorf("Got %v expected %v\n", got, expected) } } + +// BenchmarkVCardValues guards the flattening cost of VCardProperty.Values, +// which Tel/Fax now call once per property rather than twice. +func BenchmarkVCardValues(b *testing.B) { + p := &VCardProperty{ + Name: "tel", + Type: "uri", + Parameters: map[string][]string{"type": {"voice"}}, + Value: "tel:+1.5551234567", + } + + b.ReportAllocs() + for range b.N { + _ = p.Values() + } +} From cbf2bd832b27a20f5bc42206d4a002976f615bd7 Mon Sep 17 00:00:00 2001 From: Robert Thomas <31854736+wolveix@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:14:36 +0100 Subject: [PATCH 13/13] ci: exclude false-positive gosec G115 in print.go via config The uint->int conversion in Printer.indent for strings.Repeat cannot realistically overflow (indent depth is bounded by RDAP object nesting). An in-code //nolint kept getting stripped by editor tooling, so suppress it in .golangci.yml instead, which is stable across edits. --- .golangci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index f185934..a165f97 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -471,6 +471,11 @@ linters: # cli.go is the CLI argument-dispatch entrypoint; branchy by nature. - path: 'cli\.go' linters: [ nestif ] + # Printer indent depth is bounded by RDAP object nesting; the uint->int + # conversion for strings.Repeat cannot realistically overflow. + - path: 'print\.go' + text: 'G115' + linters: [ gosec ] - source: 'TODO' linters: [ godot ] - text: 'should have a package comment'