343 lines
10 KiB
Rust
343 lines
10 KiB
Rust
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());
|
|
}
|
|
}
|