Skip to main content

reallyme_valkey_kit/
config.rs

1// SPDX-FileCopyrightText: Copyright © 2026 ReallyMe LLC. All rights reserved
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use std::str::FromStr;
8use std::time::Duration;
9
10use secrecy::{ExposeSecret, SecretString};
11
12use crate::{ValkeyConfigErrorReason, ValkeyConfigField, ValkeyError, ValkeyResult};
13
14const DEFAULT_PORT: u16 = 6_379;
15const DEFAULT_DATABASE: u32 = 0;
16const DEFAULT_CONNECTION_TIMEOUT_MILLIS: u64 = 3_000;
17const DEFAULT_RESPONSE_TIMEOUT_MILLIS: u64 = 2_000;
18const DEFAULT_RETRY_ATTEMPTS: u32 = 3;
19const DEFAULT_CONCURRENCY_LIMIT: u32 = 1_024;
20const DEFAULT_PIPELINE_BUFFER_SIZE: u32 = 256;
21const MAX_HOST_BYTES: usize = 253;
22const MAX_DATABASE: u32 = 1_023;
23const MAX_KEY_PREFIX_BYTES: usize = 64;
24const MAX_CREDENTIAL_BYTES: usize = 4_096;
25const MAX_ENVIRONMENT_PREFIX_BYTES: usize = 128;
26const MAX_TLS_CA_PATH_BYTES: usize = 4_096;
27const MAX_TIMEOUT_MILLIS: u64 = 60_000;
28const MAX_RETRY_ATTEMPTS: u32 = 20;
29const MAX_CONCURRENCY_LIMIT: u32 = 65_536;
30const MAX_PIPELINE_BUFFER_SIZE: u32 = 65_536;
31
32/// Valkey transport security policy.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ValkeyTransportSecurity {
35    /// Require certificate-validated TLS.
36    RequireTls,
37    /// Permit plaintext only in explicitly selected development composition.
38    AllowPlaintextForDevelopment,
39}
40
41/// Certificate roots trusted by Valkey TLS connections.
42#[derive(Clone, PartialEq, Eq)]
43pub enum ValkeyTlsTrust {
44    /// Use the maintained public roots compiled into the Valkey client.
45    WebPkiRoots,
46    /// Trust only certificates chaining to this private CA PEM file.
47    CustomRootCertificate(PathBuf),
48}
49
50impl ValkeyTlsTrust {
51    pub(crate) fn custom_root_certificate(&self) -> Option<&Path> {
52        match self {
53            Self::WebPkiRoots => None,
54            Self::CustomRootCertificate(path) => Some(path.as_path()),
55        }
56    }
57
58    const fn mode_name(&self) -> &'static str {
59        match self {
60            Self::WebPkiRoots => "webpki-roots",
61            Self::CustomRootCertificate(_) => "custom-root-certificate",
62        }
63    }
64}
65
66impl std::fmt::Debug for ValkeyTlsTrust {
67    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        formatter.write_str(self.mode_name())
69    }
70}
71
72/// Raw Valkey configuration input.
73pub struct ValkeyConfigInput {
74    /// DNS hostname or IP address without a URI scheme.
75    pub host: String,
76    /// TCP port.
77    pub port: u16,
78    /// Logical database number.
79    pub database: u32,
80    /// Optional ACL username, treated as sensitive operational metadata.
81    pub username: Option<SecretString>,
82    /// Optional password or access token.
83    pub password: Option<SecretString>,
84    /// Prefix prepended to every binary key.
85    pub key_prefix: String,
86    /// Transport security policy.
87    pub transport_security: ValkeyTransportSecurity,
88    /// Certificate roots used when TLS is required.
89    pub tls_trust: ValkeyTlsTrust,
90    /// Connection establishment deadline.
91    pub connection_timeout_millis: u64,
92    /// Per-command response deadline.
93    pub response_timeout_millis: u64,
94    /// Automatic reconnect attempt count.
95    pub retry_attempts: u32,
96    /// Concurrent in-flight command ceiling.
97    pub concurrency_limit: u32,
98    /// Bounded outbound pipeline queue size.
99    pub pipeline_buffer_size: u32,
100}
101
102impl Default for ValkeyConfigInput {
103    fn default() -> Self {
104        Self {
105            host: String::new(),
106            port: DEFAULT_PORT,
107            database: DEFAULT_DATABASE,
108            username: None,
109            password: None,
110            key_prefix: "reallyme".to_owned(),
111            transport_security: ValkeyTransportSecurity::RequireTls,
112            tls_trust: ValkeyTlsTrust::WebPkiRoots,
113            connection_timeout_millis: DEFAULT_CONNECTION_TIMEOUT_MILLIS,
114            response_timeout_millis: DEFAULT_RESPONSE_TIMEOUT_MILLIS,
115            retry_attempts: DEFAULT_RETRY_ATTEMPTS,
116            concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
117            pipeline_buffer_size: DEFAULT_PIPELINE_BUFFER_SIZE,
118        }
119    }
120}
121
122impl std::fmt::Debug for ValkeyConfigInput {
123    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        formatter
125            .debug_struct("ValkeyConfigInput")
126            .field("host", &"<redacted-endpoint>")
127            .field("port", &self.port)
128            .field("database", &self.database)
129            .field("username", &self.username.as_ref().map(|_| "<redacted>"))
130            .field("password", &self.password.as_ref().map(|_| "<redacted>"))
131            .field("key_prefix", &self.key_prefix)
132            .field("transport_security", &self.transport_security)
133            .field("tls_trust", &self.tls_trust)
134            .field("connection_timeout_millis", &self.connection_timeout_millis)
135            .field("response_timeout_millis", &self.response_timeout_millis)
136            .field("retry_attempts", &self.retry_attempts)
137            .field("concurrency_limit", &self.concurrency_limit)
138            .field("pipeline_buffer_size", &self.pipeline_buffer_size)
139            .finish()
140    }
141}
142
143/// Validated Valkey client configuration.
144pub struct ValkeyConfig {
145    host: String,
146    port: u16,
147    database: u32,
148    username: Option<SecretString>,
149    password: Option<SecretString>,
150    key_prefix: String,
151    transport_security: ValkeyTransportSecurity,
152    tls_trust: ValkeyTlsTrust,
153    connection_timeout: Duration,
154    response_timeout: Duration,
155    retry_attempts: u32,
156    concurrency_limit: u32,
157    pipeline_buffer_size: u32,
158}
159
160impl ValkeyConfig {
161    /// Validates and constructs Valkey configuration.
162    pub fn new(input: ValkeyConfigInput) -> ValkeyResult<Self> {
163        validate_host(input.host.as_str())?;
164        if input.port == 0 {
165            return Err(config_error(
166                ValkeyConfigField::Port,
167                ValkeyConfigErrorReason::MustBePositive,
168            ));
169        }
170        if input.database > MAX_DATABASE {
171            return Err(config_error(
172                ValkeyConfigField::Database,
173                ValkeyConfigErrorReason::TooLarge,
174            ));
175        }
176        validate_key_prefix(input.key_prefix.as_str())?;
177        validate_optional_secret(input.username.as_ref(), ValkeyConfigField::Username)?;
178        validate_optional_secret(input.password.as_ref(), ValkeyConfigField::Password)?;
179        validate_positive_bounded_u64(
180            input.connection_timeout_millis,
181            MAX_TIMEOUT_MILLIS,
182            ValkeyConfigField::ConnectionTimeout,
183        )?;
184        validate_positive_bounded_u64(
185            input.response_timeout_millis,
186            MAX_TIMEOUT_MILLIS,
187            ValkeyConfigField::ResponseTimeout,
188        )?;
189        validate_bounded_u32(
190            input.retry_attempts,
191            MAX_RETRY_ATTEMPTS,
192            ValkeyConfigField::RetryAttempts,
193            false,
194        )?;
195        validate_bounded_u32(
196            input.concurrency_limit,
197            MAX_CONCURRENCY_LIMIT,
198            ValkeyConfigField::ConcurrencyLimit,
199            true,
200        )?;
201        validate_bounded_u32(
202            input.pipeline_buffer_size,
203            MAX_PIPELINE_BUFFER_SIZE,
204            ValkeyConfigField::PipelineBufferSize,
205            true,
206        )?;
207        validate_tls_trust(&input.tls_trust)?;
208        if matches!(
209            input.transport_security,
210            ValkeyTransportSecurity::AllowPlaintextForDevelopment
211        ) && matches!(input.tls_trust, ValkeyTlsTrust::CustomRootCertificate(_))
212        {
213            return Err(config_error(
214                ValkeyConfigField::TlsCaCertificatePath,
215                ValkeyConfigErrorReason::Incompatible,
216            ));
217        }
218
219        Ok(Self {
220            host: input.host,
221            port: input.port,
222            database: input.database,
223            username: input.username,
224            password: input.password,
225            key_prefix: input.key_prefix,
226            transport_security: input.transport_security,
227            tls_trust: input.tls_trust,
228            connection_timeout: Duration::from_millis(input.connection_timeout_millis),
229            response_timeout: Duration::from_millis(input.response_timeout_millis),
230            retry_attempts: input.retry_attempts,
231            concurrency_limit: input.concurrency_limit,
232            pipeline_buffer_size: input.pipeline_buffer_size,
233        })
234    }
235
236    /// Builds configuration from process environment variables using a prefix.
237    ///
238    /// For `prefix = "EXAMPLE_SEARCH"`, this reads the required
239    /// `EXAMPLE_SEARCH_VALKEY_HOST` and optional `VALKEY_PORT`,
240    /// `VALKEY_DATABASE`, `VALKEY_USERNAME`, `VALKEY_PASSWORD`,
241    /// `VALKEY_KEY_PREFIX`, `VALKEY_TLS_MODE`, and bounded connection-manager
242    /// policy variables with the same prefix. TLS defaults to required and
243    /// plaintext requires the exact `allow-plaintext-development` value.
244    pub fn from_env_prefix(prefix: &str) -> ValkeyResult<Self> {
245        let host = required_env(env_name(prefix, "VALKEY_HOST")?, ValkeyConfigField::Host)?;
246        let port = parse_env_u16(
247            env_name(prefix, "VALKEY_PORT")?,
248            DEFAULT_PORT,
249            ValkeyConfigField::Port,
250        )?;
251        let database = parse_env_u32(
252            env_name(prefix, "VALKEY_DATABASE")?,
253            DEFAULT_DATABASE,
254            ValkeyConfigField::Database,
255        )?;
256        let username = optional_env(
257            env_name(prefix, "VALKEY_USERNAME")?,
258            ValkeyConfigField::Username,
259        )?
260        .map(SecretString::from);
261        let password = optional_env(
262            env_name(prefix, "VALKEY_PASSWORD")?,
263            ValkeyConfigField::Password,
264        )?
265        .map(SecretString::from);
266        let key_prefix = optional_env(
267            env_name(prefix, "VALKEY_KEY_PREFIX")?,
268            ValkeyConfigField::KeyPrefix,
269        )?
270        .unwrap_or_else(|| "reallyme".to_owned());
271        let transport_security = match optional_env(
272            env_name(prefix, "VALKEY_TLS_MODE")?,
273            ValkeyConfigField::TransportSecurity,
274        )? {
275            Some(value) if value == "require" => ValkeyTransportSecurity::RequireTls,
276            Some(value) if value == "allow-plaintext-development" => {
277                ValkeyTransportSecurity::AllowPlaintextForDevelopment
278            }
279            Some(_) => {
280                return Err(config_error(
281                    ValkeyConfigField::TransportSecurity,
282                    ValkeyConfigErrorReason::InvalidSyntax,
283                ));
284            }
285            None => ValkeyTransportSecurity::RequireTls,
286        };
287        let tls_trust = match optional_env(
288            env_name(prefix, "VALKEY_TLS_CA_PEM_PATH")?,
289            ValkeyConfigField::TlsCaCertificatePath,
290        )? {
291            Some(value) => ValkeyTlsTrust::CustomRootCertificate(PathBuf::from(value)),
292            None => ValkeyTlsTrust::WebPkiRoots,
293        };
294        let connection_timeout_millis = parse_env_u64(
295            env_name(prefix, "VALKEY_CONNECTION_TIMEOUT_MILLIS")?,
296            DEFAULT_CONNECTION_TIMEOUT_MILLIS,
297            ValkeyConfigField::ConnectionTimeout,
298        )?;
299        let response_timeout_millis = parse_env_u64(
300            env_name(prefix, "VALKEY_RESPONSE_TIMEOUT_MILLIS")?,
301            DEFAULT_RESPONSE_TIMEOUT_MILLIS,
302            ValkeyConfigField::ResponseTimeout,
303        )?;
304        let retry_attempts = parse_env_u32(
305            env_name(prefix, "VALKEY_RETRY_ATTEMPTS")?,
306            DEFAULT_RETRY_ATTEMPTS,
307            ValkeyConfigField::RetryAttempts,
308        )?;
309        let concurrency_limit = parse_env_u32(
310            env_name(prefix, "VALKEY_CONCURRENCY_LIMIT")?,
311            DEFAULT_CONCURRENCY_LIMIT,
312            ValkeyConfigField::ConcurrencyLimit,
313        )?;
314        let pipeline_buffer_size = parse_env_u32(
315            env_name(prefix, "VALKEY_PIPELINE_BUFFER_SIZE")?,
316            DEFAULT_PIPELINE_BUFFER_SIZE,
317            ValkeyConfigField::PipelineBufferSize,
318        )?;
319
320        Self::new(ValkeyConfigInput {
321            host,
322            port,
323            database,
324            username,
325            password,
326            key_prefix,
327            transport_security,
328            tls_trust,
329            connection_timeout_millis,
330            response_timeout_millis,
331            retry_attempts,
332            concurrency_limit,
333            pipeline_buffer_size,
334        })
335    }
336
337    /// Returns the server hostname or IP address.
338    pub fn host(&self) -> &str {
339        self.host.as_str()
340    }
341    /// Returns the server TCP port.
342    pub const fn port(&self) -> u16 {
343        self.port
344    }
345    /// Returns the logical database number.
346    pub const fn database(&self) -> u32 {
347        self.database
348    }
349    /// Returns the optional ACL username.
350    pub const fn username(&self) -> Option<&SecretString> {
351        self.username.as_ref()
352    }
353    /// Returns the optional password or access token.
354    pub const fn password(&self) -> Option<&SecretString> {
355        self.password.as_ref()
356    }
357    /// Returns the key namespace prefix.
358    pub fn key_prefix(&self) -> &str {
359        self.key_prefix.as_str()
360    }
361    /// Returns the transport security policy.
362    pub const fn transport_security(&self) -> ValkeyTransportSecurity {
363        self.transport_security
364    }
365    /// Returns the certificate trust policy used for TLS connections.
366    pub const fn tls_trust(&self) -> &ValkeyTlsTrust {
367        &self.tls_trust
368    }
369    /// Returns the connection establishment deadline.
370    pub const fn connection_timeout(&self) -> Duration {
371        self.connection_timeout
372    }
373    /// Returns the command response deadline.
374    pub const fn response_timeout(&self) -> Duration {
375        self.response_timeout
376    }
377    /// Returns automatic reconnect attempts.
378    pub const fn retry_attempts(&self) -> u32 {
379        self.retry_attempts
380    }
381    /// Returns the in-flight command ceiling.
382    pub const fn concurrency_limit(&self) -> u32 {
383        self.concurrency_limit
384    }
385    /// Returns the outbound pipeline queue bound.
386    pub const fn pipeline_buffer_size(&self) -> u32 {
387        self.pipeline_buffer_size
388    }
389}
390
391impl std::fmt::Debug for ValkeyConfig {
392    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        formatter
394            .debug_struct("ValkeyConfig")
395            .field("host", &"<redacted-endpoint>")
396            .field("port", &self.port)
397            .field("database", &self.database)
398            .field("username", &self.username.as_ref().map(|_| "<redacted>"))
399            .field("password", &self.password.as_ref().map(|_| "<redacted>"))
400            .field("key_prefix", &self.key_prefix)
401            .field("transport_security", &self.transport_security)
402            .field("tls_trust", &self.tls_trust)
403            .field("connection_timeout", &self.connection_timeout)
404            .field("response_timeout", &self.response_timeout)
405            .field("retry_attempts", &self.retry_attempts)
406            .field("concurrency_limit", &self.concurrency_limit)
407            .field("pipeline_buffer_size", &self.pipeline_buffer_size)
408            .finish()
409    }
410}
411
412fn validate_host(value: &str) -> ValkeyResult<()> {
413    if value.is_empty() {
414        return Err(config_error(
415            ValkeyConfigField::Host,
416            ValkeyConfigErrorReason::Empty,
417        ));
418    }
419    if value.len() > MAX_HOST_BYTES {
420        return Err(config_error(
421            ValkeyConfigField::Host,
422            ValkeyConfigErrorReason::TooLarge,
423        ));
424    }
425    let valid_dns = value.split('.').all(|label| {
426        !label.is_empty()
427            && label.len() <= 63
428            && label
429                .bytes()
430                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
431            && !label.starts_with('-')
432            && !label.ends_with('-')
433    });
434    if IpAddr::from_str(value).is_err() && !valid_dns {
435        return Err(config_error(
436            ValkeyConfigField::Host,
437            ValkeyConfigErrorReason::InvalidSyntax,
438        ));
439    }
440    Ok(())
441}
442
443fn validate_key_prefix(value: &str) -> ValkeyResult<()> {
444    if value.is_empty() {
445        return Err(config_error(
446            ValkeyConfigField::KeyPrefix,
447            ValkeyConfigErrorReason::Empty,
448        ));
449    }
450    if value.len() > MAX_KEY_PREFIX_BYTES {
451        return Err(config_error(
452            ValkeyConfigField::KeyPrefix,
453            ValkeyConfigErrorReason::TooLarge,
454        ));
455    }
456    if !value
457        .bytes()
458        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':'))
459    {
460        return Err(config_error(
461            ValkeyConfigField::KeyPrefix,
462            ValkeyConfigErrorReason::InvalidSyntax,
463        ));
464    }
465    Ok(())
466}
467
468fn validate_tls_trust(value: &ValkeyTlsTrust) -> ValkeyResult<()> {
469    let Some(path) = value.custom_root_certificate() else {
470        return Ok(());
471    };
472    if path.as_os_str().is_empty() {
473        return Err(config_error(
474            ValkeyConfigField::TlsCaCertificatePath,
475            ValkeyConfigErrorReason::Empty,
476        ));
477    }
478    if path.as_os_str().as_encoded_bytes().len() > MAX_TLS_CA_PATH_BYTES {
479        return Err(config_error(
480            ValkeyConfigField::TlsCaCertificatePath,
481            ValkeyConfigErrorReason::TooLarge,
482        ));
483    }
484    Ok(())
485}
486
487fn validate_optional_secret(
488    value: Option<&SecretString>,
489    field: ValkeyConfigField,
490) -> ValkeyResult<()> {
491    if let Some(secret) = value {
492        if secret.expose_secret().is_empty() {
493            return Err(config_error(field, ValkeyConfigErrorReason::Empty));
494        }
495        if secret.expose_secret().len() > MAX_CREDENTIAL_BYTES {
496            return Err(config_error(field, ValkeyConfigErrorReason::TooLarge));
497        }
498    }
499    Ok(())
500}
501
502fn parse_env_u16(name: String, default: u16, field: ValkeyConfigField) -> ValkeyResult<u16> {
503    match optional_env(name, field)? {
504        Some(value) => value
505            .parse::<u16>()
506            .map_err(|_error| config_error(field, ValkeyConfigErrorReason::InvalidSyntax)),
507        None => Ok(default),
508    }
509}
510
511fn parse_env_u32(name: String, default: u32, field: ValkeyConfigField) -> ValkeyResult<u32> {
512    match optional_env(name, field)? {
513        Some(value) => value
514            .parse::<u32>()
515            .map_err(|_error| config_error(field, ValkeyConfigErrorReason::InvalidSyntax)),
516        None => Ok(default),
517    }
518}
519
520fn parse_env_u64(name: String, default: u64, field: ValkeyConfigField) -> ValkeyResult<u64> {
521    match optional_env(name, field)? {
522        Some(value) => value
523            .parse::<u64>()
524            .map_err(|_error| config_error(field, ValkeyConfigErrorReason::InvalidSyntax)),
525        None => Ok(default),
526    }
527}
528
529fn required_env(name: String, field: ValkeyConfigField) -> ValkeyResult<String> {
530    optional_env(name, field)?.ok_or_else(|| config_error(field, ValkeyConfigErrorReason::Empty))
531}
532
533fn optional_env(name: String, field: ValkeyConfigField) -> ValkeyResult<Option<String>> {
534    match std::env::var(name) {
535        Ok(value) => Ok(Some(value)),
536        Err(std::env::VarError::NotPresent) => Ok(None),
537        Err(std::env::VarError::NotUnicode(_)) => Err(config_error(
538            field,
539            ValkeyConfigErrorReason::InvalidEncoding,
540        )),
541    }
542}
543
544fn env_name(prefix: &str, suffix: &str) -> ValkeyResult<String> {
545    let prefix = prefix.trim();
546    if prefix.is_empty() {
547        return Err(config_error(
548            ValkeyConfigField::EnvironmentPrefix,
549            ValkeyConfigErrorReason::Empty,
550        ));
551    }
552    if prefix.len() > MAX_ENVIRONMENT_PREFIX_BYTES {
553        return Err(config_error(
554            ValkeyConfigField::EnvironmentPrefix,
555            ValkeyConfigErrorReason::TooLarge,
556        ));
557    }
558    if !prefix
559        .bytes()
560        .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
561    {
562        return Err(config_error(
563            ValkeyConfigField::EnvironmentPrefix,
564            ValkeyConfigErrorReason::InvalidSyntax,
565        ));
566    }
567
568    let capacity = prefix
569        .len()
570        .checked_add(suffix.len())
571        .and_then(|value| value.checked_add(1))
572        .ok_or_else(|| {
573            config_error(
574                ValkeyConfigField::EnvironmentPrefix,
575                ValkeyConfigErrorReason::TooLarge,
576            )
577        })?;
578    let mut name = String::with_capacity(capacity);
579    name.push_str(prefix);
580    name.push('_');
581    name.push_str(suffix);
582    Ok(name)
583}
584
585fn validate_positive_bounded_u64(
586    value: u64,
587    maximum: u64,
588    field: ValkeyConfigField,
589) -> ValkeyResult<()> {
590    if value == 0 {
591        return Err(config_error(field, ValkeyConfigErrorReason::MustBePositive));
592    }
593    if value > maximum {
594        return Err(config_error(field, ValkeyConfigErrorReason::TooLarge));
595    }
596    Ok(())
597}
598
599fn validate_bounded_u32(
600    value: u32,
601    maximum: u32,
602    field: ValkeyConfigField,
603    positive: bool,
604) -> ValkeyResult<()> {
605    if positive && value == 0 {
606        return Err(config_error(field, ValkeyConfigErrorReason::MustBePositive));
607    }
608    if value > maximum {
609        return Err(config_error(field, ValkeyConfigErrorReason::TooLarge));
610    }
611    Ok(())
612}
613
614const fn config_error(field: ValkeyConfigField, reason: ValkeyConfigErrorReason) -> ValkeyError {
615    ValkeyError::Config { field, reason }
616}
617
618#[cfg(test)]
619mod tests {
620    use std::path::PathBuf;
621    use std::time::Duration;
622
623    use secrecy::SecretString;
624
625    use super::{
626        MAX_CREDENTIAL_BYTES, ValkeyConfig, ValkeyConfigInput, ValkeyTlsTrust,
627        ValkeyTransportSecurity,
628    };
629    use crate::{ValkeyConfigErrorReason, ValkeyConfigField, ValkeyError};
630    use temp_env::with_vars;
631
632    fn input() -> ValkeyConfigInput {
633        ValkeyConfigInput {
634            host: "valkey.internal".to_owned(),
635            key_prefix: "reallyme:test".to_owned(),
636            transport_security: ValkeyTransportSecurity::RequireTls,
637            ..ValkeyConfigInput::default()
638        }
639    }
640
641    #[test]
642    fn valid_configuration_is_bounded_and_tls_explicit() {
643        let config = ValkeyConfig::new(input()).expect("configuration fixture should be valid");
644        assert_eq!(config.host(), "valkey.internal");
645        assert_eq!(
646            config.transport_security(),
647            ValkeyTransportSecurity::RequireTls
648        );
649        assert!(config.response_timeout().as_millis() > 0);
650    }
651
652    #[test]
653    fn host_rejects_uri_and_path_syntax() {
654        let mut value = input();
655        value.host = "rediss://valkey.internal/0".to_owned();
656        assert!(matches!(
657            ValkeyConfig::new(value),
658            Err(ValkeyError::Config {
659                field: ValkeyConfigField::Host,
660                reason: ValkeyConfigErrorReason::InvalidSyntax,
661            })
662        ));
663    }
664
665    #[test]
666    fn queue_and_concurrency_bounds_reject_zero() {
667        let mut value = input();
668        value.pipeline_buffer_size = 0;
669        assert!(matches!(
670            ValkeyConfig::new(value),
671            Err(ValkeyError::Config {
672                field: ValkeyConfigField::PipelineBufferSize,
673                reason: ValkeyConfigErrorReason::MustBePositive,
674            })
675        ));
676    }
677
678    #[test]
679    fn port_and_empty_credentials_are_rejected() {
680        let mut zero_port = input();
681        zero_port.port = 0;
682        assert!(matches!(
683            ValkeyConfig::new(zero_port),
684            Err(ValkeyError::Config {
685                field: ValkeyConfigField::Port,
686                reason: ValkeyConfigErrorReason::MustBePositive,
687            })
688        ));
689
690        let mut empty_password = input();
691        empty_password.password = Some(secrecy::SecretString::from(String::new()));
692        assert!(matches!(
693            ValkeyConfig::new(empty_password),
694            Err(ValkeyError::Config {
695                field: ValkeyConfigField::Password,
696                reason: ValkeyConfigErrorReason::Empty,
697            })
698        ));
699    }
700
701    #[test]
702    fn environment_loads_all_generic_connector_settings() {
703        with_vars(
704            [
705                ("VALKEY_KIT_TEST_VALKEY_HOST", Some("127.0.0.1")),
706                ("VALKEY_KIT_TEST_VALKEY_PORT", Some("6379")),
707                ("VALKEY_KIT_TEST_VALKEY_DATABASE", Some("7")),
708                ("VALKEY_KIT_TEST_VALKEY_USERNAME", Some("service")),
709                ("VALKEY_KIT_TEST_VALKEY_PASSWORD", Some("credential")),
710                ("VALKEY_KIT_TEST_VALKEY_KEY_PREFIX", Some("app:test")),
711                (
712                    "VALKEY_KIT_TEST_VALKEY_TLS_MODE",
713                    Some("allow-plaintext-development"),
714                ),
715                (
716                    "VALKEY_KIT_TEST_VALKEY_CONNECTION_TIMEOUT_MILLIS",
717                    Some("4000"),
718                ),
719                (
720                    "VALKEY_KIT_TEST_VALKEY_RESPONSE_TIMEOUT_MILLIS",
721                    Some("3000"),
722                ),
723                ("VALKEY_KIT_TEST_VALKEY_RETRY_ATTEMPTS", Some("4")),
724                ("VALKEY_KIT_TEST_VALKEY_CONCURRENCY_LIMIT", Some("128")),
725                ("VALKEY_KIT_TEST_VALKEY_PIPELINE_BUFFER_SIZE", Some("64")),
726            ],
727            || {
728                let config = ValkeyConfig::from_env_prefix("VALKEY_KIT_TEST")
729                    .expect("complete environment fixture should validate");
730                assert_eq!(config.host(), "127.0.0.1");
731                assert_eq!(config.port(), 6_379);
732                assert_eq!(config.database(), 7);
733                assert_eq!(config.key_prefix(), "app:test");
734                assert_eq!(config.connection_timeout(), Duration::from_secs(4));
735                assert_eq!(config.response_timeout(), Duration::from_secs(3));
736                assert_eq!(config.retry_attempts(), 4);
737                assert_eq!(config.concurrency_limit(), 128);
738                assert_eq!(config.pipeline_buffer_size(), 64);
739                assert_eq!(
740                    config.transport_security(),
741                    ValkeyTransportSecurity::AllowPlaintextForDevelopment
742                );
743            },
744        );
745    }
746
747    #[test]
748    fn environment_requires_host_and_exact_tls_mode() {
749        with_vars(
750            [
751                ("VALKEY_KIT_MISSING_VALKEY_HOST", None),
752                ("VALKEY_KIT_MISSING_VALKEY_TLS_MODE", Some("prefer")),
753            ],
754            || {
755                assert_eq!(
756                    ValkeyConfig::from_env_prefix("VALKEY_KIT_MISSING").err(),
757                    Some(ValkeyError::Config {
758                        field: ValkeyConfigField::Host,
759                        reason: ValkeyConfigErrorReason::Empty,
760                    })
761                );
762            },
763        );
764
765        with_vars(
766            [
767                ("VALKEY_KIT_TLS_VALKEY_HOST", Some("valkey.internal")),
768                ("VALKEY_KIT_TLS_VALKEY_TLS_MODE", Some("prefer")),
769            ],
770            || {
771                assert_eq!(
772                    ValkeyConfig::from_env_prefix("VALKEY_KIT_TLS").err(),
773                    Some(ValkeyError::Config {
774                        field: ValkeyConfigField::TransportSecurity,
775                        reason: ValkeyConfigErrorReason::InvalidSyntax,
776                    })
777                );
778            },
779        );
780    }
781
782    #[test]
783    fn environment_rejects_invalid_prefix_before_lookup() {
784        assert_eq!(
785            ValkeyConfig::from_env_prefix("VALKEY-KIT=INVALID").err(),
786            Some(ValkeyError::Config {
787                field: ValkeyConfigField::EnvironmentPrefix,
788                reason: ValkeyConfigErrorReason::InvalidSyntax,
789            })
790        );
791    }
792
793    #[test]
794    fn oversized_credentials_are_rejected_without_exposing_them() {
795        let mut value = input();
796        value.password = Some(SecretString::from("x".repeat(MAX_CREDENTIAL_BYTES + 1)));
797        assert_eq!(
798            ValkeyConfig::new(value).err(),
799            Some(ValkeyError::Config {
800                field: ValkeyConfigField::Password,
801                reason: ValkeyConfigErrorReason::TooLarge,
802            })
803        );
804    }
805
806    #[test]
807    fn private_ca_path_is_redacted_and_conflicts_with_plaintext() {
808        let private_path = "/private/platform/valkey-root.pem";
809        let config = ValkeyConfig::new(ValkeyConfigInput {
810            tls_trust: ValkeyTlsTrust::CustomRootCertificate(PathBuf::from(private_path)),
811            ..input()
812        })
813        .expect("private CA fixture should validate");
814        let debug = format!("{config:?}");
815        assert!(debug.contains("custom-root-certificate"));
816        assert!(!debug.contains(private_path));
817
818        assert_eq!(
819            ValkeyConfig::new(ValkeyConfigInput {
820                transport_security: ValkeyTransportSecurity::AllowPlaintextForDevelopment,
821                tls_trust: ValkeyTlsTrust::CustomRootCertificate(PathBuf::from("valkey-ca.pem")),
822                ..input()
823            })
824            .err(),
825            Some(ValkeyError::Config {
826                field: ValkeyConfigField::TlsCaCertificatePath,
827                reason: ValkeyConfigErrorReason::Incompatible,
828            })
829        );
830    }
831}