From 264fa53780a648c987306d480039e9c008112d08 Mon Sep 17 00:00:00 2001 From: Pavel <177363085+pkcll@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:28:13 -0400 Subject: [PATCH 1/5] Add resource attribute support to ChipIngress client Propagate node resource attributes via gRPC metadata headers and sanitized CloudEvent extensions. --- pkg/chipingress/client.go | 71 ++++++++++++- pkg/chipingress/client_test.go | 127 ++++++++++++++++++++++ pkg/chipingress/go.mod | 14 +-- pkg/chipingress/go.sum | 32 +++--- pkg/chipingress/header_provider.go | 69 ++++++++++++ pkg/chipingress/header_provider_test.go | 134 ++++++++++++++++++++++++ pkg/chipingress/types.go | 29 +++++ 7 files changed, 450 insertions(+), 26 deletions(-) diff --git a/pkg/chipingress/client.go b/pkg/chipingress/client.go index 6065e52f64..124a21dd0e 100644 --- a/pkg/chipingress/client.go +++ b/pkg/chipingress/client.go @@ -5,6 +5,8 @@ import ( "crypto/tls" "fmt" "net" + "sort" + "strings" "time" "github.com/google/uuid" @@ -112,14 +114,22 @@ func NewClient(address string, opts ...Opt) (Client, error) { if cfg.perRPCCredentials != nil { grpcOpts = append(grpcOpts, grpc.WithPerRPCCredentials(cfg.perRPCCredentials)) } - // Add headers as a unary interceptor, use for non-auth headers + // Add headers as unary interceptors, use for non-auth headers. + // WithChainUnaryInterceptor is used (rather than WithUnaryInterceptor) so that + // headerProvider and nopInfoHeaderProvider compose instead of the second call + // silently overriding the first (grpc.WithUnaryInterceptor is last-one-wins). + var unaryInterceptors []grpc.UnaryClientInterceptor if cfg.headerProvider != nil { - grpcOpts = append(grpcOpts, grpc.WithUnaryInterceptor(newHeaderInterceptor(cfg.headerProvider))) + unaryInterceptors = append(unaryInterceptors, newHeaderInterceptor(cfg.headerProvider)) // NOTE: not supporting streaming interceptors } if cfg.nopInfoHeaderProvider != nil { - grpcOpts = append(grpcOpts, grpc.WithUnaryInterceptor(newHeaderInterceptor(cfg.nopInfoHeaderProvider))) + unaryInterceptors = append(unaryInterceptors, newHeaderInterceptor(cfg.nopInfoHeaderProvider)) + } + + if len(unaryInterceptors) > 0 { + grpcOpts = append(grpcOpts, grpc.WithChainUnaryInterceptor(unaryInterceptors...)) } conn, err := grpc.NewClient(address, grpcOpts...) @@ -267,9 +277,60 @@ func newHeaderInterceptor(provider HeaderProvider) grpc.UnaryClientInterceptor { } } +// EventOpt configures a CloudEvent after its well-known attributes have been set by NewEvent. +type EventOpt func(*ce.Event) + +// SanitizeExtensionName lower-cases name and strips every rune outside [a-z0-9], the character +// set the CloudEvents spec requires for extension attribute names. +func SanitizeExtensionName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +// WithResourceAttributeExtensions returns an EventOpt that sets a CloudEvent extension for each +// entry in attrs, sanitizing keys via SanitizeExtensionName so they satisfy the CloudEvents +// extension-name character set. Entries that sanitize to an empty string, or that collide with a +// reserved extension name (see reservedExtensionNames), are skipped. Keys are applied in sorted +// order so that if two distinct keys sanitize to the same name, the result is deterministic. +func WithResourceAttributeExtensions(attrs map[string]string) EventOpt { + return func(event *ce.Event) { + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + + set := make(map[string]struct{}, len(attrs)) + for _, k := range keys { + name := SanitizeExtensionName(k) + if name == "" { + continue + } + if _, reserved := reservedExtensionNames[name]; reserved { + continue + } + if _, already := set[name]; already { + continue + } + set[name] = struct{}{} + event.SetExtension(name, attrs[k]) + } + } +} + // NewEvent creates a new CloudEvent with the specified domain, entity, payload, and optional attributes. func NewEvent(domain, entity string, payload []byte, attributes map[string]any) (CloudEvent, error) { + return NewEventWithOpts(domain, entity, payload, attributes) +} +// NewEventWithOpts creates a new CloudEvent like NewEvent, additionally applying opts (e.g. +// WithResourceAttributeExtensions) to the event before its data is set. +func NewEventWithOpts(domain, entity string, payload []byte, attributes map[string]any, opts ...EventOpt) (CloudEvent, error) { event := ce.NewEvent() event.SetSource(domain) event.SetType(entity) @@ -303,6 +364,10 @@ func NewEvent(domain, entity string, payload []byte, attributes map[string]any) event.SetExtension(IdempotencyKeyAttr, val) } + for _, opt := range opts { + opt(&event) + } + err := event.SetData(ceformat.ContentTypeProtobuf, payload) if err != nil { return ce.Event{}, fmt.Errorf("could not set data on event: %w", err) diff --git a/pkg/chipingress/client_test.go b/pkg/chipingress/client_test.go index c1a21c588b..800aa96e1f 100644 --- a/pkg/chipingress/client_test.go +++ b/pkg/chipingress/client_test.go @@ -3,6 +3,7 @@ package chipingress import ( "context" "fmt" + "net" "testing" "time" @@ -167,6 +168,89 @@ func TestNewEvent_IdempotencyKey(t *testing.T) { }) } +func TestSanitizeExtensionName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "snake_case", in: "chain_id", want: "chainid"}, + {name: "dotted", in: "k8s.pod.name", want: "k8spodname"}, + {name: "already valid", in: "chainid", want: "chainid"}, + {name: "upper case is lowered", in: "ChainID", want: "chainid"}, + {name: "empty", in: "", want: ""}, + {name: "all invalid characters", in: "---...", want: ""}, + {name: "mixed valid and invalid", in: "Service-Name.1", want: "servicename1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, SanitizeExtensionName(tt.in)) + }) + } +} + +func TestNewEventWithOpts_WithResourceAttributeExtensions(t *testing.T) { + payload := []byte("body") + + t.Run("sanitized keys/values land on the event", func(t *testing.T) { + attrs := map[string]string{"chain_id": "1", "k8s.pod.name": "pod-abc"} + event, err := NewEventWithOpts("domain", "entity", payload, nil, WithResourceAttributeExtensions(attrs)) + require.NoError(t, err) + ext := event.Extensions() + assert.Equal(t, "1", ext["chainid"]) + assert.Equal(t, "pod-abc", ext["k8spodname"]) + }) + + t.Run("empty sanitized name is dropped", func(t *testing.T) { + attrs := map[string]string{"---": "value"} + event, err := NewEventWithOpts("domain", "entity", payload, nil, WithResourceAttributeExtensions(attrs)) + require.NoError(t, err) + assert.Len(t, event.Extensions(), 1) // only the always-set recordedtime extension + }) + + t.Run("reserved name is skipped", func(t *testing.T) { + attrs := map[string]string{IdempotencyKeyAttr: "should-not-override", "subject": "should-not-override"} + event, err := NewEventWithOpts("domain", "entity", payload, map[string]any{IdempotencyKeyAttr: "real-key"}, WithResourceAttributeExtensions(attrs)) + require.NoError(t, err) + ext := event.Extensions() + assert.Equal(t, "real-key", ext[IdempotencyKeyAttr]) + assert.Empty(t, event.Subject()) + }) + + t.Run("duplicate sanitized names resolve deterministically to sorted-first key", func(t *testing.T) { + attrs := map[string]string{"service.name": "from-dotted", "service_name": "from-snake"} + event, err := NewEventWithOpts("domain", "entity", payload, nil, WithResourceAttributeExtensions(attrs)) + require.NoError(t, err) + // sorted order: "service.name" < "service_name" ('.' < '_' in ASCII), so the dotted key wins. + assert.Equal(t, "from-dotted", event.Extensions()["servicename"]) + }) + + t.Run("omitting all opts is a no-op", func(t *testing.T) { + event, err := NewEventWithOpts("domain", "entity", payload, nil) + require.NoError(t, err) + assert.Len(t, event.Extensions(), 1) // only the always-set recordedtime extension + }) +} + +// TestNewEvent_UnchangedSignature is a backward-compatibility guard: NewEvent's exported +// signature must stay exactly as it was before EventOpt/NewEventWithOpts were introduced, and +// must remain equivalent to calling NewEventWithOpts with no opts. +func TestNewEvent_UnchangedSignature(t *testing.T) { + payload := []byte("body") + attributes := map[string]any{"subject": "example-subject"} + + viaNewEvent, err := NewEvent("domain", "entity", payload, attributes) + require.NoError(t, err) + + viaNewEventWithOpts, err := NewEventWithOpts("domain", "entity", payload, attributes) + require.NoError(t, err) + + assert.Equal(t, viaNewEventWithOpts.Subject(), viaNewEvent.Subject()) + assert.Equal(t, viaNewEventWithOpts.Extensions()["recordedtime"].(ce.Timestamp).Truncate(time.Second), + viaNewEvent.Extensions()["recordedtime"].(ce.Timestamp).Truncate(time.Second)) + assert.Equal(t, viaNewEventWithOpts.Data(), viaNewEvent.Data()) +} + func TestEventToProto(t *testing.T) { // Create a test protobuf message testProto := pb.PingResponse{Message: "test message"} @@ -668,6 +752,49 @@ func (m *mockHeaderProvider) Headers(ctx context.Context) (map[string]string, er return m.headers, nil } +// capturingServer is a minimal ChipIngressServer that records the incoming gRPC metadata +// of the last request it handles. +type capturingServer struct { + pb.UnimplementedChipIngressServer + lastMD metadata.MD +} + +func (s *capturingServer) Ping(ctx context.Context, _ *pb.EmptyRequest) (*pb.PingResponse, error) { + s.lastMD, _ = metadata.FromIncomingContext(ctx) + return &pb.PingResponse{}, nil +} + +// TestClient_ChainedHeaderProviders is a regression test for the fix that switched from +// grpc.WithUnaryInterceptor (last-one-wins) to grpc.WithChainUnaryInterceptor: when both +// WithHeaderProvider and WithNOPLookup are configured, both providers' headers must reach +// the server, not just the one registered last. +func TestClient_ChainedHeaderProviders(t *testing.T) { + lis, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + defer lis.Close() + + srv := gp.NewServer() + capture := &capturingServer{} + pb.RegisterChipIngressServer(srv, capture) + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + client, err := NewClient(lis.Addr().String(), + WithInsecureConnection(), + WithHeaderProvider(&mockHeaderProvider{headers: map[string]string{"x-resource-attr": "chain-1"}}), + WithNOPLookup(), + ) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.Ping(t.Context(), &EmptyRequest{}) + require.NoError(t, err) + + require.NotNil(t, capture.lastMD) + assert.Equal(t, []string{"chain-1"}, capture.lastMD.Get("x-resource-attr")) + assert.Equal(t, []string{"true"}, capture.lastMD.Get("x-include-nop-info")) +} + func TestWithTLS(t *testing.T) { serverName := "example.com" config := defaultCfg diff --git a/pkg/chipingress/go.mod b/pkg/chipingress/go.mod index 33248607d5..ba5ad05e05 100644 --- a/pkg/chipingress/go.mod +++ b/pkg/chipingress/go.mod @@ -12,9 +12,9 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 - go.uber.org/zap v1.27.0 - google.golang.org/grpc v1.79.3 - google.golang.org/protobuf v1.36.10 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -30,10 +30,10 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/chipingress/go.sum b/pkg/chipingress/go.sum index 4545c8fd2a..241196adbe 100644 --- a/pkg/chipingress/go.sum +++ b/pkg/chipingress/go.sum @@ -62,24 +62,24 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/chipingress/header_provider.go b/pkg/chipingress/header_provider.go index 9a4141f98f..984b815344 100644 --- a/pkg/chipingress/header_provider.go +++ b/pkg/chipingress/header_provider.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "maps" + "sort" "sync" "sync/atomic" "time" @@ -108,6 +109,74 @@ func newStaticHeaderProvider(headers map[string]string, requireTLS bool) HeaderP return &staticHeaderProvider{headers: headers, requireTLS: requireTLS} } +// NewStaticHeaderProvider returns a HeaderProvider that always returns the given headers, +// for use with WithHeaderProvider to attach fixed, non-auth gRPC metadata (e.g. resource +// attributes) to every request. +func NewStaticHeaderProvider(headers map[string]string) HeaderProvider { + return newStaticHeaderProvider(headers, false) +} + +// SanitizeMetadataValue replaces any byte outside the printable ASCII range [0x20-0x7E] +// with '?'. grpc-go hard-fails the entire RPC when an outgoing metadata value fails this +// check (unlike the CE-extension path, where an invalid entry is simply dropped), so +// values headed for gRPC metadata must be normalized before being sent. +func SanitizeMetadataValue(val string) string { + b := []byte(val) + out := make([]byte, len(b)) + for i, c := range b { + if c >= 0x20 && c <= 0x7E { + out[i] = c + } else { + out[i] = '?' + } + } + return string(out) +} + +// SanitizeMetadataHeaders sanitizes a map of resource-attribute headers for use as outgoing +// gRPC metadata (e.g. via NewStaticHeaderProvider). Keys are sanitized with +// SanitizeExtensionName — the same strict [a-z0-9] charset used for CloudEvent extensions — +// which is a subset of grpc's allowed metadata-key charset, so a sanitized key can never trip +// grpc's key validation or the reserved "-bin" suffix, and produces the same key stem as the +// corresponding CE extension (differing only by the CloudEvents Kafka binding's "ce_" prefix +// once on the wire). Values are sanitized via SanitizeMetadataValue, since grpc-go fails the +// whole RPC on a non-printable value. Entries that sanitize to an empty key, or that collide +// with a reserved extension name (see reservedExtensionNames) or a gRPC-reserved header name +// (see reservedMetadataKeys), are skipped. Keys are applied in sorted order so duplicate +// sanitized keys resolve deterministically (first in sorted order wins), matching +// WithResourceAttributeExtensions' collision handling. +// +// Note: unlike the CloudEvents Kafka binding, gRPC metadata keys are NOT prefixed with "ce_" — +// that prefix is a CloudEvents-binding concept, not a metadata one, and reusing it here would +// collide with the CE binding's own "ce_" Kafka header if the server ever forwards gRPC +// metadata verbatim onto Kafka. +func SanitizeMetadataHeaders(in map[string]string) map[string]string { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + + out := make(map[string]string, len(in)) + for _, k := range keys { + name := SanitizeExtensionName(k) + if name == "" { + continue + } + if _, reserved := reservedExtensionNames[name]; reserved { + continue + } + if _, reserved := reservedMetadataKeys[name]; reserved { + continue + } + if _, exists := out[name]; exists { + continue + } + out[name] = SanitizeMetadataValue(in[k]) + } + return out +} + // newRotatingHeaderProvider returns a HeaderProvider that refreshes its // headers every ttl using signer. initialHeaders, if non-empty, are served // until the first rotation occurs. diff --git a/pkg/chipingress/header_provider_test.go b/pkg/chipingress/header_provider_test.go index 0c0f523e90..8069420fd2 100644 --- a/pkg/chipingress/header_provider_test.go +++ b/pkg/chipingress/header_provider_test.go @@ -4,13 +4,18 @@ import ( "context" "crypto/ed25519" "encoding/hex" + "net" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/smartcontractkit/chainlink-common/pkg/chipingress" + "github.com/smartcontractkit/chainlink-common/pkg/chipingress/pb" ) // fakeSigner is a minimal chipingress.Signer used by tests that need to @@ -260,3 +265,132 @@ func TestNewHeaderProvider(t *testing.T) { assert.Nil(t, provider) }) } + +func TestNewStaticHeaderProvider(t *testing.T) { + headers := map[string]string{"chain_id": "1", "environment": "prod"} + provider := chipingress.NewStaticHeaderProvider(headers) + require.NotNil(t, provider) + + got, err := provider.Headers(t.Context()) + require.NoError(t, err) + assert.Equal(t, headers, got) + + type tsr interface { + RequireTransportSecurity() bool + } + tlsReq, ok := provider.(tsr) + require.True(t, ok) + assert.False(t, tlsReq.RequireTransportSecurity()) +} + +func TestSanitizeMetadataValue(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "printable ASCII is unchanged", in: "chain-1_prod.v2", want: "chain-1_prod.v2"}, + {name: "empty", in: "", want: ""}, + {name: "control character replaced", in: "value\nwith\tcontrol", want: "value?with?control"}, + {name: "non-ASCII UTF-8 replaced byte-wise", in: "café", want: "caf??"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, chipingress.SanitizeMetadataValue(tt.in)) + }) + } +} + +func TestSanitizeMetadataHeaders(t *testing.T) { + t.Run("standard OTel-style keys are sanitized to the same stem as CE extensions", func(t *testing.T) { + in := map[string]string{ + "service.name": "beholder", + "chain_id": "1", + "node-operator": "acme", + } + got := chipingress.SanitizeMetadataHeaders(in) + assert.Equal(t, map[string]string{ + "servicename": "beholder", + "chainid": "1", + "nodeoperator": "acme", + }, got) + }) + + t.Run("empty-after-sanitize keys are dropped", func(t *testing.T) { + got := chipingress.SanitizeMetadataHeaders(map[string]string{"---": "value"}) + assert.Empty(t, got) + }) + + t.Run("reserved names are dropped", func(t *testing.T) { + got := chipingress.SanitizeMetadataHeaders(map[string]string{chipingress.IdempotencyKeyAttr: "should-not-appear", "subject": "should-not-appear"}) + assert.Empty(t, got) + }) + + t.Run("gRPC-reserved header 'te' is dropped", func(t *testing.T) { + got := chipingress.SanitizeMetadataHeaders(map[string]string{"te": "trailers"}) + assert.Empty(t, got) + }) + + t.Run("non-printable values are sanitized", func(t *testing.T) { + got := chipingress.SanitizeMetadataHeaders(map[string]string{"chain_id": "1\n2"}) + assert.Equal(t, "1?2", got["chainid"]) + }) + + t.Run("duplicate sanitized keys resolve deterministically to sorted-first key", func(t *testing.T) { + got := chipingress.SanitizeMetadataHeaders(map[string]string{"service.name": "from-dotted", "service_name": "from-snake"}) + // sorted order: "service.name" < "service_name" ('.' < '_' in ASCII), so the dotted key wins. + assert.Equal(t, "from-dotted", got["servicename"]) + }) +} + +// pingServer is a minimal ChipIngressServer that always answers Ping successfully. +type pingServer struct { + pb.UnimplementedChipIngressServer +} + +func (pingServer) Ping(context.Context, *pb.EmptyRequest) (*pb.PingResponse, error) { + return &pb.PingResponse{}, nil +} + +// TestSanitizeMetadataHeaders_AvoidsRPCFailure is a regression/guard test for the core reason +// SanitizeMetadataHeaders exists: grpc-go hard-fails an entire RPC (codes.Internal) when an +// outgoing metadata pair fails its charset validation. An unsanitized resource-attribute key or +// value (dots, non-printable characters) reproduces that failure; running it through +// SanitizeMetadataHeaders first must not. +func TestSanitizeMetadataHeaders_AvoidsRPCFailure(t *testing.T) { + lis, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + defer lis.Close() + + srv := grpc.NewServer() + pb.RegisterChipIngressServer(srv, pingServer{}) + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + dirty := map[string]string{"k8s.pod.name": "pod-\x01abc"} + + t.Run("unsanitized headers fail the RPC", func(t *testing.T) { + client, err := chipingress.NewClient(lis.Addr().String(), + chipingress.WithInsecureConnection(), + chipingress.WithHeaderProvider(chipingress.NewStaticHeaderProvider(dirty)), + ) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.Ping(t.Context(), &chipingress.EmptyRequest{}) + require.Error(t, err) + assert.Equal(t, codes.Internal, status.Code(err)) + }) + + t.Run("sanitized headers succeed", func(t *testing.T) { + client, err := chipingress.NewClient(lis.Addr().String(), + chipingress.WithInsecureConnection(), + chipingress.WithHeaderProvider(chipingress.NewStaticHeaderProvider(chipingress.SanitizeMetadataHeaders(dirty))), + ) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.Ping(t.Context(), &chipingress.EmptyRequest{}) + require.NoError(t, err) + }) +} diff --git a/pkg/chipingress/types.go b/pkg/chipingress/types.go index e27c18d194..54cb844477 100644 --- a/pkg/chipingress/types.go +++ b/pkg/chipingress/types.go @@ -13,6 +13,35 @@ import ( // Kafka headers named "ce_" (e.g., ce_idempotencykey), enabling downstream deduplication. const IdempotencyKeyAttr = "idempotencykey" +// reservedExtensionNames holds every CloudEvent extension name that NewEvent sets internally, +// plus the CloudEvents core context attribute names (id, source, type, specversion, time, +// subject, dataschema, datacontenttype) and the spec-forbidden "data" name. WithResourceAttributeExtensions +// consults this set so that a resource attribute can never silently overwrite event-lifecycle +// metadata or collide with a CloudEvents core attribute. +var reservedExtensionNames = map[string]struct{}{ + IdempotencyKeyAttr: {}, + "recordedtime": {}, + "id": {}, + "source": {}, + "type": {}, + "specversion": {}, + "time": {}, + "subject": {}, + "dataschema": {}, + "datacontenttype": {}, + "data": {}, +} + +// reservedMetadataKeys holds gRPC-reserved header names that could otherwise be reached by +// SanitizeExtensionName's [a-z0-9] sanitization. Verified against grpc-go v1.79.1's +// isReservedHeader: every other reserved header (pseudo-headers, "content-type", "grpc-*") +// contains a ':' or '-' that sanitization strips, so "te" is the only one actually reachable. +// SanitizeMetadataHeaders consults this set so that edge case is handled deterministically +// rather than relying on grpc's own (silent) handling of a reserved header. +var reservedMetadataKeys = map[string]struct{}{ + "te": {}, +} + type ( // Cloudevents types CloudEvent = ce.Event From d626b1a5bd912d43fa214abb7d31dd7678cb62e2 Mon Sep 17 00:00:00 2001 From: Pavel <177363085+pkcll@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:48:03 -0400 Subject: [PATCH 2/5] Unexport SanitizeExtensionName per review feedback --- pkg/chipingress/client.go | 16 ++++++++-------- pkg/chipingress/client_test.go | 4 ++-- pkg/chipingress/header_provider.go | 4 ++-- pkg/chipingress/types.go | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/chipingress/client.go b/pkg/chipingress/client.go index 124a21dd0e..8ae3484ef8 100644 --- a/pkg/chipingress/client.go +++ b/pkg/chipingress/client.go @@ -280,9 +280,9 @@ func newHeaderInterceptor(provider HeaderProvider) grpc.UnaryClientInterceptor { // EventOpt configures a CloudEvent after its well-known attributes have been set by NewEvent. type EventOpt func(*ce.Event) -// SanitizeExtensionName lower-cases name and strips every rune outside [a-z0-9], the character +// sanitizeExtensionName lower-cases name and strips every rune outside [a-z0-9], the character // set the CloudEvents spec requires for extension attribute names. -func SanitizeExtensionName(name string) string { +func sanitizeExtensionName(name string) string { var b strings.Builder for _, r := range strings.ToLower(name) { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { @@ -292,11 +292,11 @@ func SanitizeExtensionName(name string) string { return b.String() } -// WithResourceAttributeExtensions returns an EventOpt that sets a CloudEvent extension for each -// entry in attrs, sanitizing keys via SanitizeExtensionName so they satisfy the CloudEvents -// extension-name character set. Entries that sanitize to an empty string, or that collide with a -// reserved extension name (see reservedExtensionNames), are skipped. Keys are applied in sorted -// order so that if two distinct keys sanitize to the same name, the result is deterministic. + // WithResourceAttributeExtensions returns an EventOpt that sets a CloudEvent extension for each + // entry in attrs, sanitizing keys via sanitizeExtensionName so they satisfy the CloudEvents + // extension-name character set. Entries that sanitize to an empty string, or that collide with a + // reserved extension name (see reservedExtensionNames), are skipped. Keys are applied in sorted + // order so that if two distinct keys sanitize to the same name, the result is deterministic. func WithResourceAttributeExtensions(attrs map[string]string) EventOpt { return func(event *ce.Event) { keys := make([]string, 0, len(attrs)) @@ -307,7 +307,7 @@ func WithResourceAttributeExtensions(attrs map[string]string) EventOpt { set := make(map[string]struct{}, len(attrs)) for _, k := range keys { - name := SanitizeExtensionName(k) + name := sanitizeExtensionName(k) if name == "" { continue } diff --git a/pkg/chipingress/client_test.go b/pkg/chipingress/client_test.go index 800aa96e1f..d6ff155615 100644 --- a/pkg/chipingress/client_test.go +++ b/pkg/chipingress/client_test.go @@ -168,7 +168,7 @@ func TestNewEvent_IdempotencyKey(t *testing.T) { }) } -func TestSanitizeExtensionName(t *testing.T) { +func Test_sanitizeExtensionName(t *testing.T) { tests := []struct { name string in string @@ -184,7 +184,7 @@ func TestSanitizeExtensionName(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, SanitizeExtensionName(tt.in)) + assert.Equal(t, tt.want, sanitizeExtensionName(tt.in)) }) } } diff --git a/pkg/chipingress/header_provider.go b/pkg/chipingress/header_provider.go index 984b815344..360c6fa2db 100644 --- a/pkg/chipingress/header_provider.go +++ b/pkg/chipingress/header_provider.go @@ -135,7 +135,7 @@ func SanitizeMetadataValue(val string) string { // SanitizeMetadataHeaders sanitizes a map of resource-attribute headers for use as outgoing // gRPC metadata (e.g. via NewStaticHeaderProvider). Keys are sanitized with -// SanitizeExtensionName — the same strict [a-z0-9] charset used for CloudEvent extensions — +// sanitizeExtensionName — the same strict [a-z0-9] charset used for CloudEvent extensions — // which is a subset of grpc's allowed metadata-key charset, so a sanitized key can never trip // grpc's key validation or the reserved "-bin" suffix, and produces the same key stem as the // corresponding CE extension (differing only by the CloudEvents Kafka binding's "ce_" prefix @@ -159,7 +159,7 @@ func SanitizeMetadataHeaders(in map[string]string) map[string]string { out := make(map[string]string, len(in)) for _, k := range keys { - name := SanitizeExtensionName(k) + name := sanitizeExtensionName(k) if name == "" { continue } diff --git a/pkg/chipingress/types.go b/pkg/chipingress/types.go index 54cb844477..25eede3edb 100644 --- a/pkg/chipingress/types.go +++ b/pkg/chipingress/types.go @@ -33,7 +33,7 @@ var reservedExtensionNames = map[string]struct{}{ } // reservedMetadataKeys holds gRPC-reserved header names that could otherwise be reached by -// SanitizeExtensionName's [a-z0-9] sanitization. Verified against grpc-go v1.79.1's +// sanitizeExtensionName's [a-z0-9] sanitization. Verified against grpc-go v1.79.1's // isReservedHeader: every other reserved header (pseudo-headers, "content-type", "grpc-*") // contains a ':' or '-' that sanitization strips, so "te" is the only one actually reachable. // SanitizeMetadataHeaders consults this set so that edge case is handled deterministically From 9382c931dac204913d18c98c48b619d0434ffc6a Mon Sep 17 00:00:00 2001 From: Pavel <177363085+pkcll@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:55:13 -0400 Subject: [PATCH 3/5] Extract shared sanitizeResourceAttributeKeys helper per review feedback --- pkg/chipingress/client.go | 33 ++++-------------- pkg/chipingress/header_provider.go | 24 ++------------ pkg/chipingress/resource_attributes.go | 46 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 48 deletions(-) create mode 100644 pkg/chipingress/resource_attributes.go diff --git a/pkg/chipingress/client.go b/pkg/chipingress/client.go index 8ae3484ef8..357dcd3aef 100644 --- a/pkg/chipingress/client.go +++ b/pkg/chipingress/client.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "fmt" "net" - "sort" "strings" "time" @@ -292,33 +291,15 @@ func sanitizeExtensionName(name string) string { return b.String() } - // WithResourceAttributeExtensions returns an EventOpt that sets a CloudEvent extension for each - // entry in attrs, sanitizing keys via sanitizeExtensionName so they satisfy the CloudEvents - // extension-name character set. Entries that sanitize to an empty string, or that collide with a - // reserved extension name (see reservedExtensionNames), are skipped. Keys are applied in sorted - // order so that if two distinct keys sanitize to the same name, the result is deterministic. +// WithResourceAttributeExtensions returns an EventOpt that sets a CloudEvent extension for each +// entry in attrs, sanitizing keys via sanitizeExtensionName so they satisfy the CloudEvents +// extension-name character set. Entries that sanitize to an empty string, or that collide with a +// reserved extension name (see reservedExtensionNames), are skipped. Keys are applied in sorted +// order so that if two distinct keys sanitize to the same name, the result is deterministic. func WithResourceAttributeExtensions(attrs map[string]string) EventOpt { return func(event *ce.Event) { - keys := make([]string, 0, len(attrs)) - for k := range attrs { - keys = append(keys, k) - } - sort.Strings(keys) - - set := make(map[string]struct{}, len(attrs)) - for _, k := range keys { - name := sanitizeExtensionName(k) - if name == "" { - continue - } - if _, reserved := reservedExtensionNames[name]; reserved { - continue - } - if _, already := set[name]; already { - continue - } - set[name] = struct{}{} - event.SetExtension(name, attrs[k]) + for _, pair := range sanitizeResourceAttributeKeys(attrs, nil) { + event.SetExtension(pair.name, attrs[pair.key]) } } } diff --git a/pkg/chipingress/header_provider.go b/pkg/chipingress/header_provider.go index 360c6fa2db..47e2dfcd9b 100644 --- a/pkg/chipingress/header_provider.go +++ b/pkg/chipingress/header_provider.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "maps" - "sort" "sync" "sync/atomic" "time" @@ -151,28 +150,9 @@ func SanitizeMetadataValue(val string) string { // collide with the CE binding's own "ce_" Kafka header if the server ever forwards gRPC // metadata verbatim onto Kafka. func SanitizeMetadataHeaders(in map[string]string) map[string]string { - keys := make([]string, 0, len(in)) - for k := range in { - keys = append(keys, k) - } - sort.Strings(keys) - out := make(map[string]string, len(in)) - for _, k := range keys { - name := sanitizeExtensionName(k) - if name == "" { - continue - } - if _, reserved := reservedExtensionNames[name]; reserved { - continue - } - if _, reserved := reservedMetadataKeys[name]; reserved { - continue - } - if _, exists := out[name]; exists { - continue - } - out[name] = SanitizeMetadataValue(in[k]) + for _, pair := range sanitizeResourceAttributeKeys(in, reservedMetadataKeys) { + out[pair.name] = SanitizeMetadataValue(in[pair.key]) } return out } diff --git a/pkg/chipingress/resource_attributes.go b/pkg/chipingress/resource_attributes.go new file mode 100644 index 0000000000..2d5f974e8f --- /dev/null +++ b/pkg/chipingress/resource_attributes.go @@ -0,0 +1,46 @@ +package chipingress + +import "sort" + +// resourceAttrKey pairs a sanitized extension/metadata key name with the original +// resource-attribute key it was derived from. +type resourceAttrKey struct { + name string + key string +} + +// sanitizeResourceAttributeKeys returns the deduplicated, sorted list of resource-attribute +// keys that survive sanitization and reservation checks. The returned pairs contain the +// sanitized name and the original map key, so callers can apply their own value handling. +// +// Ordering is deterministic: original keys are sorted lexicographically, and if two keys +// sanitize to the same name the first one in sorted order wins. extraReserved, if non-nil, +// is consulted in addition to reservedExtensionNames. +func sanitizeResourceAttributeKeys(attrs map[string]string, extraReserved map[string]struct{}) []resourceAttrKey { + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + + seen := make(map[string]struct{}, len(attrs)) + result := make([]resourceAttrKey, 0, len(attrs)) + for _, k := range keys { + name := sanitizeExtensionName(k) + if name == "" { + continue + } + if _, reserved := reservedExtensionNames[name]; reserved { + continue + } + if _, reserved := extraReserved[name]; reserved { + continue + } + if _, already := seen[name]; already { + continue + } + seen[name] = struct{}{} + result = append(result, resourceAttrKey{name: name, key: k}) + } + return result +} From 29d764dbc25ba84e022a60023de23b7a63268fef Mon Sep 17 00:00:00 2001 From: Pavel <177363085+pkcll@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:18:38 -0400 Subject: [PATCH 4/5] Add WithResourceAttributeHeaders convenience Opt per review feedback --- pkg/chipingress/client.go | 7 +++++++ pkg/chipingress/client_test.go | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/pkg/chipingress/client.go b/pkg/chipingress/client.go index 357dcd3aef..f51ce93bb7 100644 --- a/pkg/chipingress/client.go +++ b/pkg/chipingress/client.go @@ -215,6 +215,13 @@ func WithHeaderProvider(provider HeaderProvider) Opt { return func(c *clientConfig) { c.headerProvider = provider } } +// WithResourceAttributeHeaders returns an Opt that attaches the provided resource attributes +// as sanitized gRPC metadata headers. It combines SanitizeMetadataHeaders with +// NewStaticHeaderProvider so the safe, validated path is used by default. +func WithResourceAttributeHeaders(attrs map[string]string) Opt { + return WithHeaderProvider(NewStaticHeaderProvider(SanitizeMetadataHeaders(attrs))) +} + // WithInsecureConnection configures the client to use an insecure connection (no TLS). func WithInsecureConnection() Opt { return func(config *clientConfig) { diff --git a/pkg/chipingress/client_test.go b/pkg/chipingress/client_test.go index d6ff155615..c224e1bb42 100644 --- a/pkg/chipingress/client_test.go +++ b/pkg/chipingress/client_test.go @@ -681,6 +681,19 @@ func TestOptions(t *testing.T) { assert.Equal(t, mockProvider, config.headerProvider) }) + t.Run("WithResourceAttributeHeaders", func(t *testing.T) { + config := defaultCfg + WithResourceAttributeHeaders(map[string]string{ + "Chain-ID": "1", + "id": "skipped", // reserved extension name + "chain_id": "2", // duplicate sanitized key, first wins + })(&config) + assert.NotNil(t, config.headerProvider) + headers, err := config.headerProvider.Headers(t.Context()) + require.NoError(t, err) + assert.Equal(t, map[string]string{"chainid": "1"}, headers) + }) + t.Run("WithBasicAuth", func(t *testing.T) { config := defaultCfg WithBasicAuth("user", "pass")(&config) From 88b3820b2954f9891c6c22de52583796f9bfa9da Mon Sep 17 00:00:00 2001 From: Pavel <177363085+pkcll@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:25:53 -0400 Subject: [PATCH 5/5] Remove go.mod changes from PR #2267 --- pkg/chipingress/go.mod | 14 +++++++------- pkg/chipingress/go.sum | 32 ++++++++++++++++---------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pkg/chipingress/go.mod b/pkg/chipingress/go.mod index ba5ad05e05..33248607d5 100644 --- a/pkg/chipingress/go.mod +++ b/pkg/chipingress/go.mod @@ -12,9 +12,9 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 - go.uber.org/zap v1.27.1 - google.golang.org/grpc v1.80.0 - google.golang.org/protobuf v1.36.11 + go.uber.org/zap v1.27.0 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.10 ) require ( @@ -30,10 +30,10 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/chipingress/go.sum b/pkg/chipingress/go.sum index 241196adbe..4545c8fd2a 100644 --- a/pkg/chipingress/go.sum +++ b/pkg/chipingress/go.sum @@ -62,24 +62,24 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=