diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..080d811 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Go Build +on: + pull_request: + paths: + - "**/*.go" + - "go.mod" + - "go.sum" + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - 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/.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..2c7ee3f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,48 @@ +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 + 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 + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - 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/.gitignore b/.gitignore index d040ffe..67b4662 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.*.un~ +*.DS_Store +*.log +.idea/* +.vscode/* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a165f97 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,501 @@ +# 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 ] + # 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' + 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/bootstrap/answer.go b/bootstrap/answer.go index 5647c46..b4b2637 100644 --- a/bootstrap/answer.go +++ b/bootstrap/answer.go @@ -10,8 +10,8 @@ import "net/url" type Answer struct { // 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 diff --git a/bootstrap/asn_registry.go b/bootstrap/asn_registry.go index 2988756..3cb83c2 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 } @@ -59,19 +63,19 @@ 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) + return nil, fmt.Errorf("parsing ASN registry: %w", err) } a := make([]asnRange, 0, len(registry.Entries)) var asn string var urls []*url.URL + for asn, urls = range registry.Entries { minASN, maxASN, err := parseASNRange(asn) - if err != nil { continue } @@ -93,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 } @@ -111,8 +114,8 @@ func (a *ASNRegistry) Lookup(question *Question) (*Answer, error) { } return &Answer{ - Query: fmt.Sprintf("%d", asn), Entry: entry, + Query: strconv.FormatUint(uint64(asn), 10), URLs: urls, }, nil } @@ -125,8 +128,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,14 +138,13 @@ 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, "-") 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) @@ -150,13 +152,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/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 41af98b..cd13c6c 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, } } @@ -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/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..7fd7fa2 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: @@ -192,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 } @@ -205,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) { @@ -221,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 { @@ -234,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) @@ -245,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 } @@ -255,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 } @@ -313,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 @@ -343,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 0cc30a8..f01f393 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 } @@ -48,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{ @@ -98,7 +100,6 @@ func (n *NetRegistry) Lookup(question *Question) (*Answer, error) { } _, lookupNet, err := net.ParseCIDR(input) - if err != nil { return nil, err } @@ -142,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/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/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/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/client.go b/client.go index b63f16d..9c422af 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{} @@ -114,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)) @@ -128,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{ @@ -219,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.", } } } @@ -243,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) @@ -259,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 @@ -296,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 f3f4a8e..10cce41 100644 --- a/client_error.go +++ b/client_error.go @@ -5,6 +5,7 @@ package rdap import ( + "errors" "fmt" "strings" ) @@ -28,12 +29,14 @@ type ClientError struct { Text string } +// Error returns the client error's text, implementing the error interface. func (c ClientError) Error() string { return c.Text } 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/decode_data.go b/decode_data.go index 9f05a60..c9dbe5d 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 { @@ -96,10 +99,3 @@ func (r DecodeData) UnknownFields() []string { return fields } - -func (r *DecodeData) init() { - r.isKnown = map[string]bool{} - r.values = map[string]interface{}{} - r.overrideKnownValue = map[string]bool{} - r.notes = map[string][]string{} -} diff --git a/decoder.go b/decoder.go index f682177..bd8db9d 100644 --- a/decoder.go +++ b/decoder.go @@ -10,6 +10,7 @@ import ( "reflect" "strconv" "strings" + "sync" ) // Decoder decodes an RDAP response (https://tools.ietf.org/html/rfc7483) into a Go value. @@ -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. @@ -72,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 } @@ -115,8 +117,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 +128,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. @@ -181,7 +183,6 @@ func (d *Decoder) decodeTopLevel(src map[string]interface{}) (interface{}, error _, err := d.decode("", src, result, nil) return result.Interface(), err - } // decode decodes the JSON structure |src| into the value |dst|. @@ -193,26 +194,26 @@ 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 // 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.Ptr: + 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: @@ -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 @@ -248,7 +249,6 @@ func (d *Decoder) decodeSlice(keyName string, src interface{}, dst reflect.Value // Decode into the result value. success, err := d.decode(keyName, v, reflect.Indirect(vdst), decodeData) - if err != nil { return false, err } @@ -272,12 +272,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 @@ -293,7 +293,6 @@ func (d *Decoder) decodeMap(keyName string, src interface{}, dst reflect.Value, // Decode into the result value. success, err := d.decode(keyName+":"+k, v, reflect.Indirect(vdst), decodeData) - if err != nil { return false, err } @@ -315,26 +314,25 @@ 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) { - 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 @@ -376,7 +374,7 @@ func (d *Decoder) decodeUint(keyName string, src interface{}, dst reflect.Value, } } - return success, err + return success } // decodeInt decodes |src| into the int8/16/32/64 |dst|. @@ -385,27 +383,25 @@ 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 { 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 interface{}, dst reflect.Value, } else { dst.SetInt(result) } - } - return success, err + return success } // decodeFloat64 decodes |src| into the float64 |dst|. @@ -461,25 +456,24 @@ 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) { - 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 interface{}, dst reflect.Val 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 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) { - 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 interface{}, dst reflect.Valu 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 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) { - 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 } @@ -559,8 +551,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(s) if convError != nil { d.addDecodeNote(decodeData, keyName, "error converting string to bool") result = false @@ -578,135 +570,130 @@ func (d *Decoder) decodeBool(keyName string, src interface{}, dst reflect.Value, dst.SetBool(result) - return success, err + return success } // 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) { - var err error - +func (d *Decoder) decodeStruct(keyName string, src any, dst reflect.Value, decodeData *DecodeData) (bool, 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 } - // 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) + // 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()) - // 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 - } - - // 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 } -func (d *Decoder) chooseFields(v reflect.Value) (map[string]reflect.Value, *DecodeData) { - if v.Kind() != reflect.Struct { - panic("BUG: chooseFields called on non-struct") +// 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. +} + +// 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 := range t.NumField() { + structField := t.Field(i) + index := append(append([]int{}, prefix...), i) - if structField.Type.Kind() == reflect.Ptr && 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.Ptr, - 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") != "" { @@ -734,7 +721,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 @@ -765,8 +752,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/decoder_test.go b/decoder_test.go index ba8c6d0..fa3af54 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 @@ -342,12 +341,11 @@ 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 result, err := d.Decode() - if err != nil { t.Errorf("While decoding '%s', got error: %s", jsonBlob, err) return result, false @@ -356,7 +354,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 { @@ -370,3 +368,20 @@ func runDecodeAndCompareTest(t *testing.T, target interface{}, jsonBlob string, 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/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/go.mod b/go.mod index ed0e5fe..e45c9d0 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.53.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..c609fbb 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.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.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..a8ba8fc 100644 --- a/print.go +++ b/print.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" ) @@ -51,6 +52,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 +70,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 +100,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 +125,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 +150,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 +175,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 +207,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 +227,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 +300,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 +368,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 +426,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 +443,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 +525,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 +577,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 +589,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 +625,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 +664,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) @@ -646,7 +672,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) } @@ -677,6 +703,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 +721,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 +732,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 +750,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 +768,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,23 +793,34 @@ 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 { + 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)) } +// 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 } 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)) } +// 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,49 +845,77 @@ 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 } - for k, v := range d.values { - isKnown, _ := d.isKnown[k] - isOverridden, _ := d.overrideKnownValue[k] + // 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) + if !isKnown || isOverridden { + p.printUnknown(k, d.values[k], indentLevel) } } } -func (p *Printer) printUnknown(key string, value interface{}, indentLevel uint) { - switch value.(type) { +// 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 := 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) - case []interface{}: - for _, value2 := range value.([]interface{}) { + p.printValue(key, value, indentLevel) + case []any: + for _, value2 := range value { 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{}) { - 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) } } +// cleanString returns str with output-breaking runes (newlines, carriage +// returns, and nulls) removed. func (p *Printer) cleanString(str string) string { + // 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) } +// 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/print_test.go b/print_test.go index e254fda..35db61d 100644 --- a/print_test.go +++ b/print_test.go @@ -5,6 +5,7 @@ package rdap import ( + "io" "testing" "github.com/openrdap/rdap/test" @@ -15,22 +16,53 @@ 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 } + +// 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.go b/request.go index 9400521..decad0b 100644 --- a/request.go +++ b/request.go @@ -7,6 +7,7 @@ package rdap import ( "context" "fmt" + "maps" "net" "net/url" "strconv" @@ -262,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() @@ -274,19 +275,15 @@ 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 } 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 @@ -325,15 +322,36 @@ func (r *Request) WithServer(server *url.URL) *Request { } func escapePath(text string) string { - var escaped []byte + // 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 := range len(text) { + if shouldPathEscape(text[i]) { + j = i + break + } + } + + if j == -1 { + return text + } - for i := 0; i < len(text); i++ { + // 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) { escaped = append(escaped, b) } else { - escaped = append(escaped, '%', + escaped = append( + escaped, '%', "0123456789ABCDEF"[b>>4], "0123456789ABCDEF"[b&0xF], ) @@ -369,7 +387,7 @@ func NewHelpRequest() *Request { func NewAutnumRequest(asn uint32) *Request { return &Request{ Type: AutnumRequest, - Query: fmt.Sprintf("%d", asn), + Query: strconv.FormatUint(uint64(asn), 10), } } @@ -466,8 +484,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) } @@ -490,7 +507,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/request_test.go b/request_test.go index db0a6b8..9d7fc05 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, @@ -243,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/response.go b/response.go index 4cefbe2..83052ea 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 @@ -52,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() @@ -144,10 +148,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/sandbox/file.go b/sandbox/file.go index 4b1e16d..0135ea1 100644 --- a/sandbox/file.go +++ b/sandbox/file.go @@ -14,9 +14,11 @@ 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") + return nil, errors.New("file not found in sandbox") } var body []byte @@ -26,7 +28,6 @@ func LoadFile(filename string) ([]byte, error) { } body, err := os.ReadFile(path.Join(sandboxPath, filename)) - if err != nil { log.Panic(err) } @@ -45,6 +46,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..49cff1d 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 @@ -21,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 76d46d7..37d2303 100644 --- a/test/http.go +++ b/test/http.go @@ -5,6 +5,7 @@ package test import ( + "context" "io" "log" "net/http" @@ -30,9 +31,13 @@ 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. func Start(set TestDataset) { httpmock.Activate() @@ -48,13 +53,21 @@ 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) + 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) } @@ -69,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 efee716..e4ff493 100644 --- a/test/http_test.go +++ b/test/http_test.go @@ -5,16 +5,15 @@ package test import ( - "testing" "strings" + "testing" ) 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/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 --- diff --git a/vcard.go b/vcard.go index a1f3695..222c622 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,14 +106,13 @@ 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) } default: panic("Unknown type") } - } // String returns the vCard as a multiline human readable string. For example: @@ -157,9 +157,8 @@ 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 { return nil, err } @@ -170,18 +169,18 @@ 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)") - } 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')") } - 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,10 +189,9 @@ 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 { if options.IgnoreInvalidProperties { continue @@ -208,11 +206,8 @@ func newVCardImpl(src interface{}, options VCardOptions) (*VCard, error) { return v, nil } -func decodeVCardProperty(p interface{}) (*VCardProperty, error) { - var a []interface{} - var ok bool - a, ok = p.([]interface{}) - +func decodeVCardProperty(p any) (*VCardProperty, error) { + a, ok := p.([]any) if !ok { return nil, vCardError("jCard property was not an array") } else if len(a) < 4 { @@ -220,26 +215,21 @@ func decodeVCardProperty(p interface{}) (*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") } - var value interface{} + var value any if len(a) == 4 { value, err = readValue(a[3], 0) } else { @@ -290,17 +280,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,26 +302,26 @@ func readParameters(p interface{}) (map[string][]string, error) { return params, nil } -func readValue(value interface{}, depth int) (interface{}, error) { - switch value := value.(type) { +func readValue(value any, depth int) (any, error) { + 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 - case []interface{}: + return val, nil + case []any: if depth == 3 { return "", vCardError("Structured value too deep") } - result := make([]interface{}, 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 } @@ -367,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. @@ -422,18 +412,17 @@ 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 } - if isVoice && len(p.Values()) > 0 { - return (p.Values())[0] + if isVoice { + if values := p.Values(); len(values) > 0 { + return values[0] + } } } @@ -442,17 +431,15 @@ 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") 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] } } } @@ -470,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 d395524..e27ff1a 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{ @@ -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", } @@ -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{ @@ -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() + } +}