@@ -28,8 +28,11 @@ import (
2828 "context"
2929 "crypto/sha256"
3030 "crypto/tls"
31+ "crypto/x509"
3132 "encoding/hex"
3233 "fmt"
34+ "net"
35+ "os"
3336 "strconv"
3437 "strings"
3538
@@ -59,6 +62,12 @@ const (
5962 // it is in-memory; a networked backend wants a guard. Callers asking for
6063 // more than this get the top _maxTopK results.
6164 _maxTopK = 10000
65+
66+ // _maxNsTokenLen bounds the human-readable portion of a namespace token so
67+ // a very long model name cannot produce an unbounded key prefix / index
68+ // name. The appended short hash keeps distinct namespaces collision-free
69+ // even when their sanitized prefixes are truncated to the same value.
70+ _maxNsTokenLen = 64
6271)
6372
6473// ValkeyStore implements the gRPC store Backend against Valkey Search.
@@ -122,12 +131,20 @@ func (s *ValkeyStore) Load(opts *pb.ModelOptions) error {
122131 Username : cfg .Username ,
123132 Password : cfg .Password ,
124133 ClientName : cfg .ClientName ,
134+ // SelectDB picks a logical Valkey DB (SELECT n) for deployments that use
135+ // numbered DBs for isolation. Defaults to 0; namespace prefixing already
136+ // isolates keyspaces on a shared DB.
137+ SelectDB : cfg .DB ,
125138 // Disable client-side caching: values are opaque blobs written once and
126139 // read rarely, so tracking invalidations would only add overhead.
127140 DisableCache : true ,
128141 }
129142 if cfg .UseTLS {
130- clientOpt .TLSConfig = & tls.Config {}
143+ tlsCfg , err := buildTLSConfig (cfg )
144+ if err != nil {
145+ return err
146+ }
147+ clientOpt .TLSConfig = tlsCfg
131148 }
132149
133150 // Close any client from a previous Load so a re-entrant Load does not leak
@@ -255,6 +272,35 @@ func (s *ValkeyStore) Free() error {
255272 return nil
256273}
257274
275+ // buildTLSConfig assembles the tls.Config for a VALKEY_TLS=true connection.
276+ // Go only auto-derives ServerName (SNI) from the dial address for hostnames;
277+ // for an IP-addressed endpoint (e.g. 10.0.0.5:6379) SNI is left empty and the
278+ // certificate's SANs won't match the raw IP, so verification fails. We set it
279+ // explicitly from the configured host so both hostname and IP endpoints verify.
280+ // A custom CA bundle (VALKEY_TLS_CA_CERT) and an explicit insecure-skip escape
281+ // hatch (VALKEY_TLS_SKIP_VERIFY) are supported for enterprise/self-signed setups.
282+ func buildTLSConfig (cfg Config ) (* tls.Config , error ) {
283+ tlsCfg := & tls.Config {}
284+ if host , _ , err := net .SplitHostPort (cfg .Addr ); err == nil && host != "" {
285+ tlsCfg .ServerName = host
286+ }
287+ if cfg .TLSSkipVerify {
288+ tlsCfg .InsecureSkipVerify = true
289+ }
290+ if cfg .TLSCACert != "" {
291+ pem , err := os .ReadFile (cfg .TLSCACert )
292+ if err != nil {
293+ return nil , fmt .Errorf ("valkey-store: read VALKEY_TLS_CA_CERT %q: %w" , cfg .TLSCACert , err )
294+ }
295+ pool := x509 .NewCertPool ()
296+ if ! pool .AppendCertsFromPEM (pem ) {
297+ return nil , fmt .Errorf ("valkey-store: VALKEY_TLS_CA_CERT %q: no valid certificate found" , cfg .TLSCACert )
298+ }
299+ tlsCfg .RootCAs = pool
300+ }
301+ return tlsCfg , nil
302+ }
303+
258304// ctx returns a request-scoped context bounded by the configured timeout. We
259305// never rely on the client's built-in write timeout because index back-fill
260306// and large KNN queries can legitimately exceed a short default.
@@ -332,6 +378,13 @@ func (s *ValkeyStore) StoresGet(opts *pb.StoresGetOptions) (pb.StoresGetResult,
332378 return pb.StoresGetResult {}, err
333379 }
334380
381+ // Reads pipeline the whole batch via DoMulti under ONE aggregate deadline,
382+ // unlike Set/Delete which use a per-command timeout. That asymmetry is
383+ // deliberate: HGET is non-mutating, so exhausting the deadline mid-batch
384+ // only truncates the result (surfaced as an error) — it can never leave a
385+ // partial write behind, which is the specific hazard the per-command timeout
386+ // guards against for Set/Delete. Pipelining is also safe here because these
387+ // are non-indexed reads (the indexed-DoMulti deadlock only affects writes).
335388 ctx , cancel := s .ctx ()
336389 defer cancel ()
337390
@@ -394,6 +447,11 @@ func (s *ValkeyStore) StoresDelete(opts *pb.StoresDeleteOptions) error {
394447// metric, ordered most-similar first. An empty/uncreated index returns empty
395448// slices and no error, matching local-store's empty-store behaviour.
396449func (s * ValkeyStore ) StoresFind (opts * pb.StoresFindOptions ) (pb.StoresFindResult , error ) {
450+ // Guard against a malformed gRPC request with a nil/empty Key before
451+ // dereferencing it — a nil opts.Key would otherwise panic the backend.
452+ if opts .Key == nil || len (opts .Key .Floats ) == 0 {
453+ return pb.StoresFindResult {}, fmt .Errorf ("valkey-store: Find: query key is empty" )
454+ }
397455 query := opts .Key .Floats
398456 topK := int (opts .TopK )
399457 if topK < 1 {
@@ -428,6 +486,12 @@ func (s *ValkeyStore) StoresFind(opts *pb.StoresFindOptions) (pb.StoresFindResul
428486 // SORTABLE schema attribute). LIMIT 0 topK caps the result and DIALECT 2 is
429487 // required for the =>[KNN ...] vector syntax. The __score field is still
430488 // returned in each document and read back for the similarity conversion.
489+ //
490+ // Injection-safety: the only caller-controlled value interpolated here is
491+ // topK (an int, already bounded above). _vecField and _scoreField are
492+ // compile-time constants, so this Sprintf cannot be used to inject query
493+ // syntax. Do NOT make those fields operator-configurable without sanitizing
494+ // them first — the KNN query string is otherwise built only from constants.
431495 q := fmt .Sprintf ("*=>[KNN %d @%s $q AS %s]" , topK , _vecField , _scoreField )
432496 cmd := s .client .B ().FtSearch ().Index (s .indexName ).Query (q ).
433497 Return ("3" ).Identifier (_vecField ).Identifier (_valField ).Identifier (_scoreField ).
@@ -586,7 +650,15 @@ func indexName(namespace string) string {
586650// persisted index is found again after a restart.
587651func nsToken (namespace string ) string {
588652 sum := sha256 .Sum256 ([]byte (namespace ))
589- return sanitize (namespace ) + "-" + hex .EncodeToString (sum [:])[:8 ]
653+ // Cap the human-readable part so a pathologically long model name can't
654+ // produce an unbounded key prefix / index name (which would degrade Valkey
655+ // performance). The 8-char hash suffix below already guarantees collision
656+ // resistance regardless of truncation, so trimming the readable part is safe.
657+ readable := sanitize (namespace )
658+ if len (readable ) > _maxNsTokenLen {
659+ readable = readable [:_maxNsTokenLen ]
660+ }
661+ return readable + "-" + hex .EncodeToString (sum [:])[:8 ]
590662}
591663
592664// sanitize maps a namespace to a safe token for keys/index names: alphanumeric,
0 commit comments