reallyme_foundationdb_kit/fdb/
config.rs1use std::env::{self, VarError};
8use std::fs::File;
9use std::path::Path;
10use std::time::Duration;
11
12use crate::fdb::error::{ConfigErrorReason, ConfigField, FdbError, FdbResult};
13
14const DEFAULT_API_VERSION: i32 = 730;
15const MIN_API_VERSION: i32 = 730;
20const MAX_API_VERSION: i32 = 730;
21const DEFAULT_HEALTH_CHECK_TIMEOUT_MILLIS: u64 = 5_000;
22const MAX_HEALTH_CHECK_TIMEOUT_MILLIS: u64 = 60_000;
23const MAX_CLUSTER_FILE_PATH_BYTES: usize = 4_096;
24const MAX_ENVIRONMENT_PREFIX_BYTES: usize = 128;
25
26#[derive(Clone, PartialEq, Eq)]
28pub struct FdbConfigInput {
29 pub fdb_cluster_file: Option<String>,
31 pub fdb_api_version: i32,
33 pub health_check_timeout_millis: u64,
35}
36
37impl Default for FdbConfigInput {
38 fn default() -> Self {
39 Self {
40 fdb_cluster_file: None,
41 fdb_api_version: DEFAULT_API_VERSION,
42 health_check_timeout_millis: DEFAULT_HEALTH_CHECK_TIMEOUT_MILLIS,
43 }
44 }
45}
46
47impl std::fmt::Debug for FdbConfigInput {
48 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 formatter
50 .debug_struct("FdbConfigInput")
51 .field(
52 "fdb_cluster_file",
53 &self.fdb_cluster_file.as_ref().map(|_| "<configured>"),
54 )
55 .field("fdb_api_version", &self.fdb_api_version)
56 .field(
57 "health_check_timeout_millis",
58 &self.health_check_timeout_millis,
59 )
60 .finish()
61 }
62}
63
64#[derive(Clone)]
70pub struct FdbConfig {
71 fdb_cluster_file: Option<String>,
72 fdb_api_version: i32,
73 health_check_timeout: Duration,
74}
75
76impl FdbConfig {
77 pub fn new(input: FdbConfigInput) -> FdbResult<Self> {
79 validate_api_version(input.fdb_api_version)?;
80 validate_positive_bounded_u64(
81 input.health_check_timeout_millis,
82 MAX_HEALTH_CHECK_TIMEOUT_MILLIS,
83 ConfigField::HealthCheckTimeoutMillis,
84 )?;
85 let fdb_cluster_file = input
86 .fdb_cluster_file
87 .map(validate_cluster_file_path)
88 .transpose()?;
89
90 Ok(Self {
91 fdb_cluster_file,
92 fdb_api_version: input.fdb_api_version,
93 health_check_timeout: Duration::from_millis(input.health_check_timeout_millis),
94 })
95 }
96
97 pub fn from_env() -> FdbResult<Self> {
102 Self::from_env_names(
103 "FDB_CLUSTER_FILE",
104 "FDB_API_VERSION",
105 "FDB_HEALTH_CHECK_TIMEOUT_MILLIS",
106 )
107 }
108
109 pub fn from_env_prefix(prefix: &str) -> FdbResult<Self> {
116 let cluster_file = env_name(prefix, "FDB_CLUSTER_FILE")?;
117 let api_version = env_name(prefix, "FDB_API_VERSION")?;
118 let health_timeout = env_name(prefix, "FDB_HEALTH_CHECK_TIMEOUT_MILLIS")?;
119 Self::from_env_names(&cluster_file, &api_version, &health_timeout)
120 }
121
122 fn from_env_names(
123 cluster_file_name: &str,
124 api_version_name: &str,
125 health_timeout_name: &str,
126 ) -> FdbResult<Self> {
127 Self::new(FdbConfigInput {
128 fdb_cluster_file: optional_env(cluster_file_name, ConfigField::ClusterFile)?,
129 fdb_api_version: parse_env_i32(
130 api_version_name,
131 DEFAULT_API_VERSION,
132 ConfigField::ApiVersion,
133 )?,
134 health_check_timeout_millis: parse_env_u64(
135 health_timeout_name,
136 DEFAULT_HEALTH_CHECK_TIMEOUT_MILLIS,
137 ConfigField::HealthCheckTimeoutMillis,
138 )?,
139 })
140 }
141
142 pub fn fdb_cluster_file(&self) -> Option<&str> {
144 self.fdb_cluster_file.as_deref()
145 }
146
147 pub const fn fdb_api_version(&self) -> i32 {
149 self.fdb_api_version
150 }
151
152 pub const fn health_check_timeout(&self) -> Duration {
154 self.health_check_timeout
155 }
156}
157
158impl std::fmt::Debug for FdbConfig {
159 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 formatter
161 .debug_struct("FdbConfig")
162 .field(
163 "fdb_cluster_file",
164 &self.fdb_cluster_file.as_ref().map(|_| "<configured>"),
165 )
166 .field("fdb_api_version", &self.fdb_api_version)
167 .field("health_check_timeout", &self.health_check_timeout)
168 .finish()
169 }
170}
171
172fn validate_api_version(value: i32) -> FdbResult<()> {
173 if !(MIN_API_VERSION..=MAX_API_VERSION).contains(&value) {
174 return Err(config_error(ConfigErrorReason::ValueOutOfBounds {
175 field: ConfigField::ApiVersion,
176 }));
177 }
178 Ok(())
179}
180
181fn validate_cluster_file_path(path: String) -> FdbResult<String> {
182 if path.is_empty() {
183 return Err(config_error(ConfigErrorReason::Empty));
184 }
185 if path.len() > MAX_CLUSTER_FILE_PATH_BYTES || path.contains('\0') {
186 return Err(config_error(ConfigErrorReason::InvalidClusterFilePath));
187 }
188
189 let file = File::open(Path::new(&path))
190 .map_err(|_| config_error(ConfigErrorReason::MissingClusterFile))?;
191 let metadata = file
192 .metadata()
193 .map_err(|_| config_error(ConfigErrorReason::MissingClusterFile))?;
194 if !metadata.is_file() {
195 return Err(config_error(ConfigErrorReason::InvalidClusterFilePath));
196 }
197
198 Ok(path)
199}
200
201fn validate_positive_bounded_u64(value: u64, maximum: u64, field: ConfigField) -> FdbResult<()> {
202 if value == 0 || value > maximum {
203 return Err(config_error(ConfigErrorReason::ValueOutOfBounds { field }));
204 }
205 Ok(())
206}
207
208fn env_name(prefix: &str, suffix: &str) -> FdbResult<String> {
209 if prefix.is_empty()
210 || prefix.len() > MAX_ENVIRONMENT_PREFIX_BYTES
211 || !prefix
212 .bytes()
213 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
214 {
215 return Err(config_error(ConfigErrorReason::InvalidEnvironmentPrefix));
216 }
217 Ok(format!("{prefix}_{suffix}"))
218}
219
220fn optional_env(name: &str, field: ConfigField) -> FdbResult<Option<String>> {
221 match env::var(name) {
222 Ok(value) => Ok(Some(value)),
223 Err(VarError::NotPresent) => Ok(None),
224 Err(VarError::NotUnicode(_)) => {
225 Err(config_error(ConfigErrorReason::InvalidEncoding { field }))
226 }
227 }
228}
229
230fn parse_env_i32(name: &str, default: i32, field: ConfigField) -> FdbResult<i32> {
231 optional_env(name, field)?
232 .map(|value| {
233 value
234 .parse::<i32>()
235 .map_err(|_| config_error(ConfigErrorReason::InvalidInteger { field }))
236 })
237 .transpose()
238 .map(|value| value.unwrap_or(default))
239}
240
241fn parse_env_u64(name: &str, default: u64, field: ConfigField) -> FdbResult<u64> {
242 optional_env(name, field)?
243 .map(|value| {
244 value
245 .parse::<u64>()
246 .map_err(|_| config_error(ConfigErrorReason::InvalidInteger { field }))
247 })
248 .transpose()
249 .map(|value| value.unwrap_or(default))
250}
251
252const fn config_error(reason: ConfigErrorReason) -> FdbError {
253 FdbError::Config { reason }
254}
255
256#[cfg(test)]
257mod tests {
258 use std::fs::{self, OpenOptions};
259
260 use temp_env::with_vars;
261
262 use super::{FdbConfig, FdbConfigInput, env_name};
263 use crate::fdb::error::{ConfigErrorReason, ConfigField, FdbError};
264
265 #[test]
266 fn config_defaults_apply_when_env_is_unset() {
267 with_vars(
268 [
269 ("FDB_API_VERSION", None::<&str>),
270 ("FDB_CLUSTER_FILE", None::<&str>),
271 ("FDB_HEALTH_CHECK_TIMEOUT_MILLIS", None::<&str>),
272 ],
273 || {
274 let result = FdbConfig::from_env();
275 assert!(result.is_ok());
276 let config = result.unwrap_or_else(|_| unreachable!());
277 assert_eq!(config.fdb_api_version(), 730);
278 assert_eq!(config.health_check_timeout().as_millis(), 5_000);
279 assert!(config.fdb_cluster_file().is_none());
280 },
281 );
282 }
283
284 #[test]
285 fn config_rejects_empty_cluster_file_path() {
286 let result = FdbConfig::new(FdbConfigInput {
287 fdb_cluster_file: Some(String::new()),
288 ..FdbConfigInput::default()
289 });
290 assert!(matches!(
291 result,
292 Err(FdbError::Config {
293 reason: ConfigErrorReason::Empty,
294 })
295 ));
296 }
297
298 #[test]
299 fn config_rejects_missing_cluster_file_path() {
300 let result = FdbConfig::new(FdbConfigInput {
301 fdb_cluster_file: Some("/tmp/__reallyme_does_not_exist".to_owned()),
302 ..FdbConfigInput::default()
303 });
304 assert!(matches!(
305 result,
306 Err(FdbError::Config {
307 reason: ConfigErrorReason::MissingClusterFile,
308 })
309 ));
310 }
311
312 #[test]
313 fn config_validates_existing_cluster_file_path() {
314 let mut path = std::env::temp_dir();
315 path.push(format!("reallyme-fdb-cluster-{}.conf", std::process::id()));
316 let created = OpenOptions::new()
317 .create_new(true)
318 .write(true)
319 .open(&path)
320 .map(|_| ());
321 assert!(created.is_ok());
322
323 let result = path.to_str().map(|value| {
324 FdbConfig::new(FdbConfigInput {
325 fdb_cluster_file: Some(value.to_owned()),
326 ..FdbConfigInput::default()
327 })
328 });
329 assert!(result.is_some_and(|value| value.is_ok()));
330 assert!(fs::remove_file(path).is_ok());
331 }
332
333 #[test]
334 fn health_check_timeout_is_bounded() {
335 for timeout in [0, 60_001] {
336 let result = FdbConfig::new(FdbConfigInput {
337 health_check_timeout_millis: timeout,
338 ..FdbConfigInput::default()
339 });
340 assert!(matches!(
341 result,
342 Err(FdbError::Config {
343 reason: ConfigErrorReason::ValueOutOfBounds {
344 field: ConfigField::HealthCheckTimeoutMillis,
345 },
346 })
347 ));
348 }
349 }
350
351 #[test]
352 fn runtime_api_must_match_compiled_foundationdb_api() {
353 let result = FdbConfig::new(FdbConfigInput {
354 fdb_api_version: 740,
355 ..FdbConfigInput::default()
356 });
357 assert!(matches!(
358 result,
359 Err(FdbError::Config {
360 reason: ConfigErrorReason::ValueOutOfBounds {
361 field: ConfigField::ApiVersion,
362 },
363 })
364 ));
365 }
366
367 #[test]
368 fn cluster_file_path_length_is_bounded_before_file_access() {
369 let result = FdbConfig::new(FdbConfigInput {
370 fdb_cluster_file: Some("x".repeat(4_097)),
371 ..FdbConfigInput::default()
372 });
373 assert!(matches!(
374 result,
375 Err(FdbError::Config {
376 reason: ConfigErrorReason::InvalidClusterFilePath,
377 })
378 ));
379 }
380
381 #[test]
382 fn environment_prefix_is_strictly_validated() {
383 assert_eq!(
384 env_name("HANDLE", "FDB_API_VERSION").as_deref(),
385 Ok("HANDLE_FDB_API_VERSION")
386 );
387 for invalid_prefix in ["handle", "", "HANDLE-DATA"] {
388 assert!(matches!(
389 env_name(invalid_prefix, "FDB_API_VERSION"),
390 Err(FdbError::Config {
391 reason: ConfigErrorReason::InvalidEnvironmentPrefix,
392 })
393 ));
394 }
395 }
396
397 #[test]
398 fn api_version_must_parse_and_stay_in_supported_range() {
399 with_vars([("FDB_API_VERSION", Some("not-a-number"))], || {
400 assert!(matches!(
401 FdbConfig::from_env(),
402 Err(FdbError::Config {
403 reason: ConfigErrorReason::InvalidInteger {
404 field: ConfigField::ApiVersion,
405 },
406 })
407 ));
408 });
409 with_vars([("FDB_API_VERSION", Some("100"))], || {
410 assert!(matches!(
411 FdbConfig::from_env(),
412 Err(FdbError::Config {
413 reason: ConfigErrorReason::ValueOutOfBounds {
414 field: ConfigField::ApiVersion,
415 },
416 })
417 ));
418 });
419 }
420
421 #[test]
422 fn debug_output_redacts_cluster_file_path() {
423 let input = FdbConfigInput {
424 fdb_cluster_file: Some("/sensitive/deployment/topology/fdb.cluster".to_owned()),
425 ..FdbConfigInput::default()
426 };
427 let rendered = format!("{input:?}");
428 assert!(!rendered.contains("sensitive"));
429 assert!(rendered.contains("<configured>"));
430 }
431}