1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ValkeyTransportSecurity {
35 RequireTls,
37 AllowPlaintextForDevelopment,
39}
40
41#[derive(Clone, PartialEq, Eq)]
43pub enum ValkeyTlsTrust {
44 WebPkiRoots,
46 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
72pub struct ValkeyConfigInput {
74 pub host: String,
76 pub port: u16,
78 pub database: u32,
80 pub username: Option<SecretString>,
82 pub password: Option<SecretString>,
84 pub key_prefix: String,
86 pub transport_security: ValkeyTransportSecurity,
88 pub tls_trust: ValkeyTlsTrust,
90 pub connection_timeout_millis: u64,
92 pub response_timeout_millis: u64,
94 pub retry_attempts: u32,
96 pub concurrency_limit: u32,
98 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
143pub 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 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 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 pub fn host(&self) -> &str {
339 self.host.as_str()
340 }
341 pub const fn port(&self) -> u16 {
343 self.port
344 }
345 pub const fn database(&self) -> u32 {
347 self.database
348 }
349 pub const fn username(&self) -> Option<&SecretString> {
351 self.username.as_ref()
352 }
353 pub const fn password(&self) -> Option<&SecretString> {
355 self.password.as_ref()
356 }
357 pub fn key_prefix(&self) -> &str {
359 self.key_prefix.as_str()
360 }
361 pub const fn transport_security(&self) -> ValkeyTransportSecurity {
363 self.transport_security
364 }
365 pub const fn tls_trust(&self) -> &ValkeyTlsTrust {
367 &self.tls_trust
368 }
369 pub const fn connection_timeout(&self) -> Duration {
371 self.connection_timeout
372 }
373 pub const fn response_timeout(&self) -> Duration {
375 self.response_timeout
376 }
377 pub const fn retry_attempts(&self) -> u32 {
379 self.retry_attempts
380 }
381 pub const fn concurrency_limit(&self) -> u32 {
383 self.concurrency_limit
384 }
385 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}