Skip to main content

reallyme_foundationdb_kit/fdb/
config.rs

1// SPDX-FileCopyrightText: Copyright © 2026 ReallyMe LLC. All rights reserved
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Validated FoundationDB connection configuration.
6
7use 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;
15// The Rust binding generates version-gated tenant-management behavior at
16// compile time. Allowing a runtime API that differs from the generated binding
17// can select incompatible system-key layouts, so production configuration
18// must match the compiled API exactly.
19const 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/// Raw FoundationDB connector configuration input.
27#[derive(Clone, PartialEq, Eq)]
28pub struct FdbConfigInput {
29    /// Optional FoundationDB cluster-file path.
30    pub fdb_cluster_file: Option<String>,
31    /// FoundationDB C API compatibility version selected for the process.
32    pub fdb_api_version: i32,
33    /// Deadline for connector health and startup connectivity checks.
34    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/// Validated FoundationDB connector configuration.
65///
66/// The cluster-file path is deliberately redacted from `Debug` output. Cluster
67/// files are not credentials, but their paths commonly disclose deployment
68/// topology and should not become routine log metadata.
69#[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    /// Constructs validated connector configuration.
78    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    /// Builds configuration from the conventional process environment.
98    ///
99    /// Reads `FDB_CLUSTER_FILE`, `FDB_API_VERSION`, and
100    /// `FDB_HEALTH_CHECK_TIMEOUT_MILLIS`.
101    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    /// Builds configuration from service-prefixed environment variables.
110    ///
111    /// For `prefix = "HANDLE"`, this reads `HANDLE_FDB_CLUSTER_FILE`,
112    /// `HANDLE_FDB_API_VERSION`, and
113    /// `HANDLE_FDB_HEALTH_CHECK_TIMEOUT_MILLIS`. FoundationDB supports one
114    /// client network per process, so one prefix must be selected per process.
115    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    /// Returns the optional validated cluster-file path.
143    pub fn fdb_cluster_file(&self) -> Option<&str> {
144        self.fdb_cluster_file.as_deref()
145    }
146
147    /// Returns the configured FoundationDB runtime API version.
148    pub const fn fdb_api_version(&self) -> i32 {
149        self.fdb_api_version
150    }
151
152    /// Returns the deadline applied to a complete connector health probe.
153    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}