Skip to main content

reallyme_foundationdb_kit/fdb/
config.rs

1// SPDX-FileCopyrightText: 2026 ReallyMe LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Validated FoundationDB connection configuration.
5
6use std::env::{self, VarError};
7use std::fs::File;
8use std::path::Path;
9use std::time::Duration;
10
11use crate::fdb::error::{ConfigErrorReason, ConfigField, FdbError, FdbResult};
12
13const DEFAULT_API_VERSION: i32 = 730;
14// The Rust binding generates version-gated tenant-management behavior at
15// compile time. Allowing a runtime API that differs from the generated binding
16// can select incompatible system-key layouts, so production configuration
17// must match the compiled API exactly.
18const MIN_API_VERSION: i32 = 730;
19const MAX_API_VERSION: i32 = 730;
20const DEFAULT_HEALTH_CHECK_TIMEOUT_MILLIS: u64 = 5_000;
21const MAX_HEALTH_CHECK_TIMEOUT_MILLIS: u64 = 60_000;
22const MAX_CLUSTER_FILE_PATH_BYTES: usize = 4_096;
23const MAX_ENVIRONMENT_PREFIX_BYTES: usize = 128;
24
25/// Raw FoundationDB connector configuration input.
26#[derive(Clone, PartialEq, Eq)]
27pub struct FdbConfigInput {
28    /// Optional FoundationDB cluster-file path.
29    pub fdb_cluster_file: Option<String>,
30    /// FoundationDB C API compatibility version selected for the process.
31    pub fdb_api_version: i32,
32    /// Deadline for connector health and startup connectivity checks.
33    pub health_check_timeout_millis: u64,
34}
35
36impl Default for FdbConfigInput {
37    fn default() -> Self {
38        Self {
39            fdb_cluster_file: None,
40            fdb_api_version: DEFAULT_API_VERSION,
41            health_check_timeout_millis: DEFAULT_HEALTH_CHECK_TIMEOUT_MILLIS,
42        }
43    }
44}
45
46impl std::fmt::Debug for FdbConfigInput {
47    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        formatter
49            .debug_struct("FdbConfigInput")
50            .field(
51                "fdb_cluster_file",
52                &self.fdb_cluster_file.as_ref().map(|_| "<configured>"),
53            )
54            .field("fdb_api_version", &self.fdb_api_version)
55            .field(
56                "health_check_timeout_millis",
57                &self.health_check_timeout_millis,
58            )
59            .finish()
60    }
61}
62
63/// Validated FoundationDB connector configuration.
64///
65/// The cluster-file path is deliberately redacted from `Debug` output. Cluster
66/// files are not credentials, but their paths commonly disclose deployment
67/// topology and should not become routine log metadata.
68#[derive(Clone)]
69pub struct FdbConfig {
70    fdb_cluster_file: Option<String>,
71    fdb_api_version: i32,
72    health_check_timeout: Duration,
73}
74
75impl FdbConfig {
76    /// Constructs validated connector configuration.
77    pub fn new(input: FdbConfigInput) -> FdbResult<Self> {
78        validate_api_version(input.fdb_api_version)?;
79        validate_positive_bounded_u64(
80            input.health_check_timeout_millis,
81            MAX_HEALTH_CHECK_TIMEOUT_MILLIS,
82            ConfigField::HealthCheckTimeoutMillis,
83        )?;
84        let fdb_cluster_file = input
85            .fdb_cluster_file
86            .map(validate_cluster_file_path)
87            .transpose()?;
88
89        Ok(Self {
90            fdb_cluster_file,
91            fdb_api_version: input.fdb_api_version,
92            health_check_timeout: Duration::from_millis(input.health_check_timeout_millis),
93        })
94    }
95
96    /// Builds configuration from the conventional process environment.
97    ///
98    /// Reads `FDB_CLUSTER_FILE`, `FDB_API_VERSION`, and
99    /// `FDB_HEALTH_CHECK_TIMEOUT_MILLIS`.
100    pub fn from_env() -> FdbResult<Self> {
101        Self::from_env_names(
102            "FDB_CLUSTER_FILE",
103            "FDB_API_VERSION",
104            "FDB_HEALTH_CHECK_TIMEOUT_MILLIS",
105        )
106    }
107
108    /// Builds configuration from service-prefixed environment variables.
109    ///
110    /// For `prefix = "HANDLE"`, this reads `HANDLE_FDB_CLUSTER_FILE`,
111    /// `HANDLE_FDB_API_VERSION`, and
112    /// `HANDLE_FDB_HEALTH_CHECK_TIMEOUT_MILLIS`. FoundationDB supports one
113    /// client network per process, so one prefix must be selected per process.
114    pub fn from_env_prefix(prefix: &str) -> FdbResult<Self> {
115        let cluster_file = env_name(prefix, "FDB_CLUSTER_FILE")?;
116        let api_version = env_name(prefix, "FDB_API_VERSION")?;
117        let health_timeout = env_name(prefix, "FDB_HEALTH_CHECK_TIMEOUT_MILLIS")?;
118        Self::from_env_names(&cluster_file, &api_version, &health_timeout)
119    }
120
121    fn from_env_names(
122        cluster_file_name: &str,
123        api_version_name: &str,
124        health_timeout_name: &str,
125    ) -> FdbResult<Self> {
126        Self::new(FdbConfigInput {
127            fdb_cluster_file: optional_env(cluster_file_name, ConfigField::ClusterFile)?,
128            fdb_api_version: parse_env_i32(
129                api_version_name,
130                DEFAULT_API_VERSION,
131                ConfigField::ApiVersion,
132            )?,
133            health_check_timeout_millis: parse_env_u64(
134                health_timeout_name,
135                DEFAULT_HEALTH_CHECK_TIMEOUT_MILLIS,
136                ConfigField::HealthCheckTimeoutMillis,
137            )?,
138        })
139    }
140
141    /// Returns the optional validated cluster-file path.
142    pub fn fdb_cluster_file(&self) -> Option<&str> {
143        self.fdb_cluster_file.as_deref()
144    }
145
146    /// Returns the configured FoundationDB runtime API version.
147    pub const fn fdb_api_version(&self) -> i32 {
148        self.fdb_api_version
149    }
150
151    /// Returns the deadline applied to a complete connector health probe.
152    pub const fn health_check_timeout(&self) -> Duration {
153        self.health_check_timeout
154    }
155}
156
157impl std::fmt::Debug for FdbConfig {
158    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        formatter
160            .debug_struct("FdbConfig")
161            .field(
162                "fdb_cluster_file",
163                &self.fdb_cluster_file.as_ref().map(|_| "<configured>"),
164            )
165            .field("fdb_api_version", &self.fdb_api_version)
166            .field("health_check_timeout", &self.health_check_timeout)
167            .finish()
168    }
169}
170
171fn validate_api_version(value: i32) -> FdbResult<()> {
172    if !(MIN_API_VERSION..=MAX_API_VERSION).contains(&value) {
173        return Err(config_error(ConfigErrorReason::ValueOutOfBounds {
174            field: ConfigField::ApiVersion,
175        }));
176    }
177    Ok(())
178}
179
180fn validate_cluster_file_path(path: String) -> FdbResult<String> {
181    if path.is_empty() {
182        return Err(config_error(ConfigErrorReason::Empty));
183    }
184    if path.len() > MAX_CLUSTER_FILE_PATH_BYTES || path.contains('\0') {
185        return Err(config_error(ConfigErrorReason::InvalidClusterFilePath));
186    }
187
188    let file = File::open(Path::new(&path))
189        .map_err(|_| config_error(ConfigErrorReason::MissingClusterFile))?;
190    let metadata = file
191        .metadata()
192        .map_err(|_| config_error(ConfigErrorReason::MissingClusterFile))?;
193    if !metadata.is_file() {
194        return Err(config_error(ConfigErrorReason::InvalidClusterFilePath));
195    }
196
197    Ok(path)
198}
199
200fn validate_positive_bounded_u64(value: u64, maximum: u64, field: ConfigField) -> FdbResult<()> {
201    if value == 0 || value > maximum {
202        return Err(config_error(ConfigErrorReason::ValueOutOfBounds { field }));
203    }
204    Ok(())
205}
206
207fn env_name(prefix: &str, suffix: &str) -> FdbResult<String> {
208    if prefix.is_empty()
209        || prefix.len() > MAX_ENVIRONMENT_PREFIX_BYTES
210        || !prefix
211            .bytes()
212            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
213    {
214        return Err(config_error(ConfigErrorReason::InvalidEnvironmentPrefix));
215    }
216    Ok(format!("{prefix}_{suffix}"))
217}
218
219fn optional_env(name: &str, field: ConfigField) -> FdbResult<Option<String>> {
220    match env::var(name) {
221        Ok(value) => Ok(Some(value)),
222        Err(VarError::NotPresent) => Ok(None),
223        Err(VarError::NotUnicode(_)) => {
224            Err(config_error(ConfigErrorReason::InvalidEncoding { field }))
225        }
226    }
227}
228
229fn parse_env_i32(name: &str, default: i32, field: ConfigField) -> FdbResult<i32> {
230    optional_env(name, field)?
231        .map(|value| {
232            value
233                .parse::<i32>()
234                .map_err(|_| config_error(ConfigErrorReason::InvalidInteger { field }))
235        })
236        .transpose()
237        .map(|value| value.unwrap_or(default))
238}
239
240fn parse_env_u64(name: &str, default: u64, field: ConfigField) -> FdbResult<u64> {
241    optional_env(name, field)?
242        .map(|value| {
243            value
244                .parse::<u64>()
245                .map_err(|_| config_error(ConfigErrorReason::InvalidInteger { field }))
246        })
247        .transpose()
248        .map(|value| value.unwrap_or(default))
249}
250
251const fn config_error(reason: ConfigErrorReason) -> FdbError {
252    FdbError::Config { reason }
253}
254
255#[cfg(test)]
256#[path = "config_tests.rs"]
257mod tests;