Files
smartdb/rust/crates/rustdb-config/src/lib.rs
T

343 lines
10 KiB
Rust
Raw Normal View History

use serde::{Deserialize, Serialize};
/// Storage backend type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StorageType {
Memory,
File,
}
impl Default for StorageType {
fn default() -> Self {
StorageType::Memory
}
}
/// Top-level configuration for RustDb server.
/// Field names use camelCase to match the TypeScript SmartdbServer options.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustDbOptions {
/// TCP port to listen on (default: 27017)
#[serde(default = "default_port")]
pub port: u16,
/// Host/IP to bind to (default: "127.0.0.1")
#[serde(default = "default_host")]
pub host: String,
/// Unix socket path (overrides TCP if set)
#[serde(skip_serializing_if = "Option::is_none")]
pub socket_path: Option<String>,
/// Storage backend type
#[serde(default)]
pub storage: StorageType,
/// Base path for file storage (required when storage = "file")
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_path: Option<String>,
/// Path for periodic persistence of in-memory data
#[serde(skip_serializing_if = "Option::is_none")]
pub persist_path: Option<String>,
/// Interval in ms for periodic persistence (default: 60000)
#[serde(default = "default_persist_interval")]
pub persist_interval_ms: u64,
/// Authentication configuration.
#[serde(default)]
pub auth: AuthOptions,
/// TLS transport configuration for TCP listeners.
#[serde(default)]
pub tls: TlsOptions,
}
/// Authentication configuration for the embedded server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthOptions {
/// Whether clients must authenticate before issuing protected commands.
#[serde(default)]
pub enabled: bool,
/// Bootstrap users loaded at startup. Passwords are converted into SCRAM credentials in memory.
#[serde(default)]
pub users: Vec<AuthUserOptions>,
/// Optional path for persisted SCRAM user metadata. Stores derived credentials, never plaintext passwords.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub users_path: Option<String>,
/// SCRAM iteration count used for bootstrap credentials.
#[serde(default = "default_scram_iterations")]
pub scram_iterations: u32,
}
impl Default for AuthOptions {
fn default() -> Self {
Self {
enabled: false,
users: Vec::new(),
users_path: None,
scram_iterations: default_scram_iterations(),
}
}
}
/// TLS transport configuration for the embedded server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TlsOptions {
/// Whether TCP client connections must use TLS.
#[serde(default)]
pub enabled: bool,
/// PEM-encoded server certificate chain.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_path: Option<String>,
/// PEM-encoded server private key.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub key_path: Option<String>,
/// PEM-encoded client CA roots for mTLS verification.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub ca_path: Option<String>,
/// Require clients to present a certificate signed by caPath.
#[serde(default)]
pub require_client_cert: bool,
}
impl Default for TlsOptions {
fn default() -> Self {
Self {
enabled: false,
cert_path: None,
key_path: None,
ca_path: None,
require_client_cert: false,
}
}
}
/// A bootstrap user for SCRAM authentication.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthUserOptions {
pub username: String,
pub password: String,
#[serde(default = "default_auth_database")]
pub database: String,
#[serde(default)]
pub roles: Vec<String>,
}
fn default_port() -> u16 {
27017
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_persist_interval() -> u64 {
60000
}
fn default_scram_iterations() -> u32 {
15000
}
fn default_auth_database() -> String {
"admin".to_string()
}
impl Default for RustDbOptions {
fn default() -> Self {
Self {
port: default_port(),
host: default_host(),
socket_path: None,
storage: StorageType::default(),
storage_path: None,
persist_path: None,
persist_interval_ms: default_persist_interval(),
auth: AuthOptions::default(),
tls: TlsOptions::default(),
}
}
}
impl RustDbOptions {
/// Load options from a JSON config file.
pub fn from_file(path: &str) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::IoError(e.to_string()))?;
let options: Self = serde_json::from_str(&content)
.map_err(|e| ConfigError::ParseError(e.to_string()))?;
options.validate()?;
Ok(options)
}
/// Validate the configuration.
pub fn validate(&self) -> Result<(), ConfigError> {
if self.storage == StorageType::File && self.storage_path.is_none() {
return Err(ConfigError::ValidationError(
"storagePath is required when storage is 'file'".to_string(),
));
}
if self.auth.enabled {
if self.auth.users.is_empty() && self.auth.users_path.is_none() {
return Err(ConfigError::ValidationError(
"auth.users or auth.usersPath must be set when auth.enabled is true".to_string(),
));
}
if self.auth.scram_iterations < 4096 {
return Err(ConfigError::ValidationError(
"auth.scramIterations must be at least 4096".to_string(),
));
}
for user in &self.auth.users {
if user.username.is_empty() {
return Err(ConfigError::ValidationError(
"auth.users[].username must not be empty".to_string(),
));
}
if user.password.is_empty() {
return Err(ConfigError::ValidationError(
format!("auth user '{}' must have a non-empty password", user.username),
));
}
if user.database.is_empty() {
return Err(ConfigError::ValidationError(
format!("auth user '{}' must have a non-empty database", user.username),
));
}
}
}
if self.tls.enabled {
if self.socket_path.is_some() {
return Err(ConfigError::ValidationError(
"tls.enabled is only supported for TCP listeners".to_string(),
));
}
if self.tls.cert_path.as_deref().unwrap_or_default().is_empty() {
return Err(ConfigError::ValidationError(
"tls.certPath is required when tls.enabled is true".to_string(),
));
}
if self.tls.key_path.as_deref().unwrap_or_default().is_empty() {
return Err(ConfigError::ValidationError(
"tls.keyPath is required when tls.enabled is true".to_string(),
));
}
if self.tls.require_client_cert
&& self.tls.ca_path.as_deref().unwrap_or_default().is_empty()
{
return Err(ConfigError::ValidationError(
"tls.caPath is required when tls.requireClientCert is true".to_string(),
));
}
}
Ok(())
}
/// Get the connection URI for this server configuration.
pub fn connection_uri(&self) -> String {
if let Some(ref socket_path) = self.socket_path {
let encoded = urlencoding(socket_path);
format!("mongodb://{}", encoded)
} else {
let base = format!("mongodb://{}:{}", self.host, self.port);
if self.tls.enabled {
format!("{}/?tls=true", base)
} else {
base
}
}
}
}
/// Simple URL encoding for socket paths (encode / as %2F, etc.)
fn urlencoding(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' => "%2F".to_string(),
':' => "%3A".to_string(),
' ' => "%20".to_string(),
_ => c.to_string(),
})
.collect()
}
/// Configuration errors.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
IoError(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Validation error: {0}")]
ValidationError(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_options() {
let opts = RustDbOptions::default();
assert_eq!(opts.port, 27017);
assert_eq!(opts.host, "127.0.0.1");
assert!(opts.socket_path.is_none());
assert_eq!(opts.storage, StorageType::Memory);
}
#[test]
fn test_deserialize_from_json() {
let json = r#"{"port": 27018, "storage": "file", "storagePath": "./data"}"#;
let opts: RustDbOptions = serde_json::from_str(json).unwrap();
assert_eq!(opts.port, 27018);
assert_eq!(opts.storage, StorageType::File);
assert_eq!(opts.storage_path, Some("./data".to_string()));
}
#[test]
fn test_connection_uri_tcp() {
let opts = RustDbOptions::default();
assert_eq!(opts.connection_uri(), "mongodb://127.0.0.1:27017");
}
#[test]
fn test_connection_uri_socket() {
let opts = RustDbOptions {
socket_path: Some("/tmp/smartdb-test.sock".to_string()),
..Default::default()
};
assert_eq!(
opts.connection_uri(),
"mongodb://%2Ftmp%2Fsmartdb-test.sock"
);
}
#[test]
fn test_validation_file_storage_requires_path() {
let opts = RustDbOptions {
storage: StorageType::File,
storage_path: None,
..Default::default()
};
assert!(opts.validate().is_err());
}
}