This commit is contained in:
Stephan D
2026-03-10 12:31:09 +01:00
parent d87e709f43
commit e77d1ab793
287 changed files with 2089 additions and 1550 deletions

View File

@@ -1,196 +1,47 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
default: none
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gochecknoinits
- gomoddirectives
- wrapcheck
- wsl
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- funlen
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []
- cyclop
- dupl
- funlen
- gocognit
- gocyclo
- ireturn
- lll
- mnd
- nestif
- nlreturn
- noinlineerr
- paralleltest
- tagliatelle
- testpackage
- varnamelen
- wsl_v5

View File

@@ -31,6 +31,7 @@ func Load(path string) (*Config, error) {
return nil, merrors.InvalidArgument("config: path is empty")
}
//nolint:gosec // config path is provided by process startup arguments/config.
data, err := os.ReadFile(path)
if err != nil {
return nil, merrors.InternalWrap(err, "config: failed to read file")
@@ -73,8 +74,10 @@ func Load(path string) (*Config, error) {
}
if _, ok := sourceSet[driver]; !ok {
return nil, merrors.InvalidArgument( //nolint:lll
"config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
return nil, merrors.InvalidArgument(
"config: pair references unknown source: "+driver.String(),
"pairs."+driver.String(),
)
}
processed := make([]PairConfig, len(pairList))
@@ -86,14 +89,16 @@ func Load(path string) (*Config, error) {
pair.Symbol = strings.TrimSpace(pair.Symbol)
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
return nil, merrors.InvalidArgument( //nolint:lll
"config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
return nil, merrors.InvalidArgument(
"config: pair entries must define base, quote, and symbol",
"pairs."+driver.String(),
)
}
if strings.TrimSpace(pair.Provider) == "" {
pair.Provider = strings.ToLower(driver.String())
}
processed[idx] = pair
flattened = append(flattened, Pair{
PairConfig: pair,

View File

@@ -14,6 +14,8 @@ import (
"go.uber.org/zap"
)
var errNoSnapshot = errors.New("snapshot not found")
func TestParseDecimal(t *testing.T) {
got, err := parseDecimal("123.456")
if err != nil {
@@ -191,19 +193,9 @@ func (r *ratesStoreStub) UpsertSnapshot(_ context.Context, snapshot *model.RateS
}
func (r *ratesStoreStub) LatestSnapshot(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
return nil, nil
return nil, errNoSnapshot
}
type repositoryStub struct {
rates storage.RatesStore
}
func (r *repositoryStub) Ping(context.Context) error { return nil }
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
func (r *repositoryStub) Quotes() storage.QuotesStore { return nil }
func (r *repositoryStub) Pairs() storage.PairStore { return nil }
func (r *repositoryStub) Currencies() storage.CurrencyStore { return nil }
type connectorStub struct {
id mmarket.Driver
ticker *mmarket.Ticker

View File

@@ -116,7 +116,11 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
return nil, merrors.InternalWrap(err, "binance: request failed")
}
defer resp.Body.Close()
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.logger.Warn("Failed to close Binance response body", zap.Error(closeErr))
}
}()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))

View File

@@ -122,9 +122,11 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
logger,
client,
httpClientOptions{
userAgent: userAgent,
accept: acceptHeader,
referer: referer,
userAgent: userAgent,
accept: acceptHeader,
referer: referer,
allowedScheme: parsed.Scheme,
allowedHost: parsed.Host,
},
),
base: strings.TrimRight(parsed.String(), "/"),
@@ -200,7 +202,11 @@ func (c *cbrConnector) refreshDirectory() error {
)
return merrors.InternalWrap(err, "cbr: directory request failed")
}
defer resp.Body.Close()
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.logger.Warn("Failed to close CBR daily response body", zap.Error(closeErr))
}
}()
if resp.StatusCode != http.StatusOK {
c.logger.Warn(
@@ -258,7 +264,11 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
)
return "", merrors.InternalWrap(err, "cbr: daily request failed")
}
defer resp.Body.Close()
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.logger.Warn("Failed to close CBR historical response body", zap.Error(closeErr))
}
}()
if resp.StatusCode != http.StatusOK {
c.logger.Warn(
@@ -326,7 +336,11 @@ func (c *cbrConnector) fetchHistoricalRate( //nolint:funlen
)
return "", merrors.InternalWrap(err, "cbr: historical request failed")
}
defer resp.Body.Close()
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.logger.Warn("Failed to close CBR historical response body", zap.Error(closeErr))
}
}()
if resp.StatusCode != http.StatusOK {
c.logger.Warn(

View File

@@ -2,7 +2,10 @@ package cbr
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/tech/sendico/pkg/mlogger"
@@ -14,17 +17,28 @@ const (
defaultAccept = "application/xml,text/xml;q=0.9,*/*;q=0.8"
)
var (
errNilRequestURL = errors.New("http_client: request URL is nil")
errRelativeRequestURL = errors.New("http_client: request URL must be absolute")
errUnexpectedURLScheme = errors.New("http_client: unexpected URL scheme")
errUnexpectedURLHost = errors.New("http_client: unexpected URL host")
)
// httpClient wraps http.Client to ensure CBR requests always carry required headers.
type httpClient struct {
client *http.Client
headers http.Header
logger mlogger.Logger
client *http.Client
headers http.Header
logger mlogger.Logger
allowedScheme string
allowedHost string
}
type httpClientOptions struct {
userAgent string
accept string
referer string
userAgent string
accept string
referer string
allowedScheme string
allowedHost string
}
func newHTTPClient(logger mlogger.Logger, client *http.Client, opts httpClientOptions) *httpClient {
@@ -42,6 +56,20 @@ func newHTTPClient(logger mlogger.Logger, client *http.Client, opts httpClientOp
if strings.TrimSpace(referer) == "" {
referer = defaultCBRBaseURL
}
allowedScheme := strings.ToLower(strings.TrimSpace(opts.allowedScheme))
allowedHost := strings.ToLower(strings.TrimSpace(opts.allowedHost))
if allowedScheme == "" || allowedHost == "" {
if parsed, err := url.Parse(referer); err == nil {
if allowedScheme == "" {
allowedScheme = strings.ToLower(parsed.Scheme)
}
if allowedHost == "" {
allowedHost = strings.ToLower(parsed.Host)
}
}
}
httpLogger := logger.Named("http_client")
headers := make(http.Header, 3)
@@ -53,9 +81,11 @@ func newHTTPClient(logger mlogger.Logger, client *http.Client, opts httpClientOp
zap.String("accept", accept), zap.String("referrer", referer))
return &httpClient{
client: client,
headers: headers,
logger: httpLogger,
client: client,
headers: headers,
logger: httpLogger,
allowedScheme: allowedScheme,
allowedHost: allowedHost,
}
}
@@ -74,6 +104,13 @@ func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
enriched.Header.Add(key, value)
}
}
if err := h.validateRequestTarget(enriched.URL); err != nil {
h.logger.Warn("HTTP request blocked by target validation", zap.Error(err), zap.String("method", req.Method))
return nil, err
}
//nolint:gosec // request URL is constrained in validateRequestTarget before any outbound call.
r, err := h.client.Do(enriched)
if err != nil {
h.logger.Warn("HTTP request failed", zap.Error(err), zap.String("method", req.Method),
@@ -85,3 +122,26 @@ func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
func (h *httpClient) headerValue(name string) string {
return h.headers.Get(name)
}
func (h *httpClient) validateRequestTarget(requestURL *url.URL) error {
if requestURL == nil {
return errNilRequestURL
}
if !requestURL.IsAbs() {
return errRelativeRequestURL
}
scheme := strings.ToLower(requestURL.Scheme)
host := strings.ToLower(requestURL.Host)
if h.allowedScheme != "" && scheme != h.allowedScheme {
return fmt.Errorf("%w: %q", errUnexpectedURLScheme, requestURL.Scheme)
}
if h.allowedHost != "" && host != h.allowedHost {
return fmt.Errorf("%w: %q", errUnexpectedURLHost, requestURL.Host)
}
return nil
}

View File

@@ -125,7 +125,11 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
return nil, merrors.InternalWrap(err, "coingecko: request failed")
}
defer resp.Body.Close()
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
c.logger.Warn("Failed to close CoinGecko response body", zap.Error(closeErr))
}
}()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))

View File

@@ -1,4 +1,5 @@
package common //nolint:revive // package provides shared market connector utilities
// Package common provides shared market connector utilities.
package common
import (
"strconv"

View File

@@ -32,7 +32,9 @@ func main() {
appVersion := appversion.Create()
if *versionFlag {
fmt.Fprintln(os.Stdout, appVersion.Print())
if _, err := fmt.Fprintln(os.Stdout, appVersion.Print()); err != nil {
return
}
return
}