Skip to main content

uni_common/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use std::path::{Path, PathBuf};
5use std::thread;
6use std::time::Duration;
7
8#[derive(Clone, Debug)]
9pub struct CompactionConfig {
10    /// Enable background compaction (default: true)
11    pub enabled: bool,
12
13    /// Max L1 runs before triggering compaction (default: 4)
14    pub max_l1_runs: usize,
15
16    /// Max L1 size in bytes before compaction (default: 256MB)
17    pub max_l1_size_bytes: u64,
18
19    /// Max age of oldest L1 run before compaction (default: 1 hour)
20    pub max_l1_age: Duration,
21
22    /// Background check interval (default: 30s)
23    pub check_interval: Duration,
24
25    /// Number of compaction worker threads (default: 1)
26    pub worker_threads: usize,
27}
28
29impl Default for CompactionConfig {
30    fn default() -> Self {
31        Self {
32            enabled: true,
33            max_l1_runs: 4,
34            max_l1_size_bytes: 256 * 1024 * 1024,
35            max_l1_age: Duration::from_secs(3600),
36            check_interval: Duration::from_secs(30),
37            worker_threads: 1,
38        }
39    }
40}
41
42/// Configuration for background index rebuilding.
43#[derive(Clone, Debug)]
44pub struct IndexRebuildConfig {
45    /// Maximum number of retry attempts for failed index builds (default: 3).
46    pub max_retries: u32,
47
48    /// Delay between retry attempts (default: 60s).
49    pub retry_delay: Duration,
50
51    /// How often to check for pending index rebuild tasks (default: 5s).
52    pub worker_check_interval: Duration,
53
54    /// Row growth ratio to trigger rebuild (default: 0.5 = 50%). Set 0.0 to disable.
55    pub growth_trigger_ratio: f64,
56
57    /// Max index age before rebuild. `None` disables the time-based trigger.
58    pub max_index_age: Option<Duration>,
59
60    /// Enable post-flush automatic rebuild scheduling (default: false).
61    pub auto_rebuild_enabled: bool,
62}
63
64impl Default for IndexRebuildConfig {
65    fn default() -> Self {
66        Self {
67            max_retries: 3,
68            retry_delay: Duration::from_secs(60),
69            worker_check_interval: Duration::from_secs(5),
70            growth_trigger_ratio: 0.5,
71            max_index_age: None,
72            auto_rebuild_enabled: false,
73        }
74    }
75}
76
77#[derive(Clone, Copy, Debug)]
78pub struct WriteThrottleConfig {
79    /// L1 run count to start throttling (default: 8)
80    pub soft_limit: usize,
81
82    /// L1 run count to stop writes entirely (default: 16)
83    pub hard_limit: usize,
84
85    /// Base delay when throttling (default: 10ms)
86    pub base_delay: Duration,
87}
88
89impl Default for WriteThrottleConfig {
90    fn default() -> Self {
91        Self {
92            soft_limit: 8,
93            hard_limit: 16,
94            base_delay: Duration::from_millis(10),
95        }
96    }
97}
98
99#[derive(Clone, Debug)]
100pub struct ObjectStoreConfig {
101    pub connect_timeout: Duration,
102    pub read_timeout: Duration,
103    pub write_timeout: Duration,
104    pub max_retries: u32,
105    pub retry_backoff_base: Duration,
106    pub retry_backoff_max: Duration,
107}
108
109impl Default for ObjectStoreConfig {
110    fn default() -> Self {
111        Self {
112            connect_timeout: Duration::from_secs(10),
113            read_timeout: Duration::from_secs(30),
114            write_timeout: Duration::from_secs(60),
115            max_retries: 3,
116            retry_backoff_base: Duration::from_millis(100),
117            retry_backoff_max: Duration::from_secs(10),
118        }
119    }
120}
121
122/// Security configuration for file system operations.
123/// Controls which paths can be accessed by BACKUP, COPY, and EXPORT commands.
124///
125/// Disabled by default for backward compatibility in embedded mode.
126/// MUST be enabled for server mode with untrusted clients.
127#[derive(Clone, Debug, Default)]
128pub struct FileSandboxConfig {
129    /// If true, file operations are restricted to allowed_paths.
130    /// If false, all paths are allowed (NOT RECOMMENDED for server mode).
131    pub enabled: bool,
132
133    /// List of allowed base directories for file operations.
134    /// Paths must be absolute and canonical.
135    /// File operations are only allowed within these directories.
136    pub allowed_paths: Vec<PathBuf>,
137}
138
139/// Deployment mode for the database.
140///
141/// Used to determine appropriate security defaults.
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143pub enum DeploymentMode {
144    /// Embedded/library mode where the host application controls access.
145    /// File sandbox is disabled by default for backward compatibility.
146    #[default]
147    Embedded,
148    /// Server mode with untrusted clients.
149    /// File sandbox is enabled by default with restricted paths.
150    Server,
151}
152
153/// HTTP server configuration.
154///
155/// Controls CORS, authentication, and other HTTP-related security settings.
156///
157/// # Security
158///
159/// **CWE-942 (Overly Permissive CORS)**, **CWE-306 (Missing Authentication)**:
160/// Production deployments should configure explicit `allowed_origins` and
161/// enable API key authentication.
162#[derive(Clone, Debug)]
163pub struct ServerConfig {
164    /// Allowed CORS origins.
165    ///
166    /// - Empty vector: No CORS headers (most restrictive)
167    /// - `["*"]`: Allow all origins (NOT RECOMMENDED for production)
168    /// - Explicit list: Only allow specified origins (RECOMMENDED)
169    ///
170    /// # Security
171    ///
172    /// **CWE-942**: Using `["*"]` allows any website to make requests to
173    /// your server, potentially exposing sensitive data.
174    pub allowed_origins: Vec<String>,
175
176    /// Optional API key for request authentication.
177    ///
178    /// When set, all API requests must include the header:
179    /// `X-API-Key: <key>`
180    ///
181    /// # Security
182    ///
183    /// **CWE-306**: Without authentication, any client can execute queries.
184    /// Enable this for any deployment accessible beyond localhost.
185    pub api_key: Option<String>,
186
187    /// Whether to require API key for metrics endpoint.
188    ///
189    /// Default: false (metrics are public for observability tooling)
190    pub require_auth_for_metrics: bool,
191}
192
193impl Default for ServerConfig {
194    fn default() -> Self {
195        Self {
196            // Default to localhost-only origin for development safety
197            allowed_origins: vec!["http://localhost:3000".to_string()],
198            api_key: None,
199            require_auth_for_metrics: false,
200        }
201    }
202}
203
204impl ServerConfig {
205    /// Create a permissive config for local development only.
206    ///
207    /// # Security
208    ///
209    /// **WARNING**: Do not use in production. This config allows all CORS origins
210    /// and has no authentication.
211    #[must_use]
212    pub fn development() -> Self {
213        Self {
214            allowed_origins: vec!["*".to_string()],
215            api_key: None,
216            require_auth_for_metrics: false,
217        }
218    }
219
220    /// Create a production config with explicit origins and required API key.
221    ///
222    /// # Panics
223    ///
224    /// Panics if `api_key` is empty.
225    #[must_use]
226    pub fn production(allowed_origins: Vec<String>, api_key: String) -> Self {
227        assert!(
228            !api_key.is_empty(),
229            "API key must not be empty for production"
230        );
231        Self {
232            allowed_origins,
233            api_key: Some(api_key),
234            require_auth_for_metrics: true,
235        }
236    }
237
238    /// Returns a security warning if the config is insecure.
239    pub fn security_warning(&self) -> Option<&'static str> {
240        if self.allowed_origins.contains(&"*".to_string()) && self.api_key.is_none() {
241            Some(
242                "Server config has permissive CORS (allow all origins) and no API key. \
243                 This is insecure for production deployments.",
244            )
245        } else if self.allowed_origins.contains(&"*".to_string()) {
246            Some(
247                "Server config has permissive CORS (allow all origins). \
248                 Consider restricting to specific origins for production.",
249            )
250        } else if self.api_key.is_none() {
251            Some(
252                "Server config has no API key authentication. \
253                 Enable api_key for production deployments.",
254            )
255        } else {
256            None
257        }
258    }
259}
260
261impl FileSandboxConfig {
262    /// Creates a sandboxed config that only allows operations in the specified directories.
263    pub fn sandboxed(paths: Vec<PathBuf>) -> Self {
264        Self {
265            enabled: true,
266            allowed_paths: paths,
267        }
268    }
269
270    /// Creates a config with appropriate defaults for the deployment mode.
271    ///
272    /// # Security
273    ///
274    /// - **Embedded mode**: Sandbox disabled (host application controls access)
275    /// - **Server mode**: Sandbox enabled with default paths `/var/lib/uni/data` and
276    ///   `/var/lib/uni/backups`
277    ///
278    /// **CWE-22 (Path Traversal)**: Server deployments MUST enable the sandbox to
279    /// prevent arbitrary file read/write via BACKUP, COPY, and EXPORT commands.
280    pub fn default_for_mode(mode: DeploymentMode) -> Self {
281        match mode {
282            DeploymentMode::Embedded => Self {
283                enabled: false,
284                allowed_paths: vec![],
285            },
286            DeploymentMode::Server => Self {
287                enabled: true,
288                allowed_paths: vec![
289                    PathBuf::from("/var/lib/uni/data"),
290                    PathBuf::from("/var/lib/uni/backups"),
291                ],
292            },
293        }
294    }
295
296    /// Returns a security warning message if the sandbox is disabled.
297    ///
298    /// Call this at startup to alert administrators about potential security risks.
299    /// Returns `Some(message)` if a warning should be displayed, `None` otherwise.
300    ///
301    /// # Security
302    ///
303    /// **CWE-22 (Path Traversal)**, **CWE-73 (External Control of File Name)**:
304    /// Disabled sandbox allows unrestricted filesystem access for BACKUP, COPY,
305    /// and EXPORT commands, which can lead to:
306    /// - Arbitrary file read/write in server deployments
307    /// - Data exfiltration to attacker-controlled paths
308    /// - Potential privilege escalation via file overwrites
309    ///
310    /// # Example
311    ///
312    /// ```ignore
313    /// if let Some(warning) = config.file_sandbox.security_warning() {
314    ///     tracing::warn!(target: "uni_db::security", "{}", warning);
315    /// }
316    /// ```
317    pub fn security_warning(&self) -> Option<&'static str> {
318        if !self.enabled {
319            Some(
320                "File sandbox is DISABLED. This allows unrestricted filesystem access \
321                 for BACKUP, COPY, and EXPORT commands. Enable sandbox for server \
322                 deployments: file_sandbox.enabled = true",
323            )
324        } else {
325            None
326        }
327    }
328
329    /// Returns whether the sandbox is in a potentially insecure state.
330    ///
331    /// Returns `true` if the sandbox is disabled or enabled with no allowed paths.
332    pub fn is_potentially_insecure(&self) -> bool {
333        !self.enabled || self.allowed_paths.is_empty()
334    }
335
336    /// Validate that a path is within the allowed sandbox.
337    /// Returns Ok(canonical_path) if allowed, Err if not.
338    pub fn validate_path(&self, path: &str) -> Result<PathBuf, String> {
339        if !self.enabled {
340            // Sandbox disabled - allow all paths
341            return Ok(PathBuf::from(path));
342        }
343
344        if self.allowed_paths.is_empty() {
345            return Err("File sandbox is enabled but no allowed paths configured".to_string());
346        }
347
348        // Resolve the path to canonical form to prevent traversal attacks
349        let input_path = Path::new(path);
350
351        // For paths that don't exist yet (e.g., export destinations), we need to
352        // check their parent directory exists and is within allowed paths
353        let canonical = if input_path.exists() {
354            input_path
355                .canonicalize()
356                .map_err(|e| format!("Failed to canonicalize path: {}", e))?
357        } else {
358            // Path doesn't exist - check parent
359            let parent = input_path
360                .parent()
361                .ok_or_else(|| "Invalid path: no parent directory".to_string())?;
362            if !parent.exists() {
363                return Err(format!(
364                    "Parent directory does not exist: {}",
365                    parent.display()
366                ));
367            }
368            let canonical_parent = parent
369                .canonicalize()
370                .map_err(|e| format!("Failed to canonicalize parent: {}", e))?;
371            // Reconstruct with canonical parent + original filename
372            let filename = input_path
373                .file_name()
374                .ok_or_else(|| "Invalid path: no filename".to_string())?;
375            canonical_parent.join(filename)
376        };
377
378        // Check if the canonical path is within any allowed directory
379        for allowed in &self.allowed_paths {
380            // Ensure allowed path is canonical too
381            let canonical_allowed = if allowed.exists() {
382                allowed.canonicalize().unwrap_or_else(|_| allowed.clone())
383            } else {
384                allowed.clone()
385            };
386
387            if canonical.starts_with(&canonical_allowed) {
388                return Ok(canonical);
389            }
390        }
391
392        Err(format!(
393            "Path '{}' is outside allowed sandbox directories. Allowed: {:?}",
394            path, self.allowed_paths
395        ))
396    }
397}
398
399#[derive(Clone, Debug)]
400pub struct UniConfig {
401    /// Maximum adjacency cache size in bytes (default: 1GB)
402    pub cache_size: usize,
403
404    /// Number of worker threads for parallel execution
405    pub parallelism: usize,
406
407    /// Size of each data morsel/batch (number of rows)
408    pub batch_size: usize,
409
410    /// Maximum size of traversal frontier before pruning
411    pub max_frontier_size: usize,
412
413    /// Auto-flush threshold for L0 buffer (default: 10_000 mutations)
414    pub auto_flush_threshold: usize,
415
416    /// Auto-flush interval for L0 buffer (default: 5 seconds).
417    /// Flush triggers if time elapsed AND mutation count >= auto_flush_min_mutations.
418    /// Set to None to disable time-based flush.
419    pub auto_flush_interval: Option<Duration>,
420
421    /// Minimum mutations required before time-based flush triggers (default: 1).
422    /// Prevents unnecessary flushes when there's minimal activity.
423    pub auto_flush_min_mutations: usize,
424
425    /// Enable write-ahead logging (default: true)
426    pub wal_enabled: bool,
427
428    /// Compaction configuration
429    pub compaction: CompactionConfig,
430
431    /// Write throttling configuration
432    pub throttle: WriteThrottleConfig,
433
434    /// File sandbox configuration for BACKUP/COPY/EXPORT commands.
435    /// MUST be enabled with allowed paths in server mode to prevent arbitrary file access.
436    pub file_sandbox: FileSandboxConfig,
437
438    /// Default query execution timeout (default: 30s)
439    pub query_timeout: Duration,
440
441    /// Default maximum memory per query (default: 1GB)
442    pub max_query_memory: usize,
443
444    /// Maximum transaction buffer memory in bytes (default: 1GB).
445    /// Limits memory usage during transactions to prevent OOM.
446    pub max_transaction_memory: usize,
447
448    /// Maximum rows for in-memory compaction (default: 5M, ~725MB at 145 bytes/row).
449    /// Configurable OOM guard to prevent memory exhaustion during compaction.
450    pub max_compaction_rows: usize,
451
452    /// Enable in-memory VID-to-labels index for O(1) lookups (default: true).
453    /// Memory cost: ~42 bytes per vertex (1M vertices ≈ 42MB).
454    pub enable_vid_labels_index: bool,
455
456    /// Object store resilience configuration
457    pub object_store: ObjectStoreConfig,
458
459    /// Background index rebuild configuration
460    pub index_rebuild: IndexRebuildConfig,
461}
462
463impl Default for UniConfig {
464    fn default() -> Self {
465        let parallelism = thread::available_parallelism()
466            .map(|n| n.get())
467            .unwrap_or(4);
468
469        Self {
470            cache_size: 1024 * 1024 * 1024, // 1GB
471            parallelism,
472            batch_size: 1024, // Default morsel size
473            max_frontier_size: 1_000_000,
474            auto_flush_threshold: 10_000,
475            auto_flush_interval: Some(Duration::from_secs(5)),
476            auto_flush_min_mutations: 1,
477            wal_enabled: true,
478            compaction: CompactionConfig::default(),
479            throttle: WriteThrottleConfig::default(),
480            file_sandbox: FileSandboxConfig::default(),
481            query_timeout: Duration::from_secs(30),
482            max_query_memory: 1024 * 1024 * 1024,       // 1GB
483            max_transaction_memory: 1024 * 1024 * 1024, // 1GB
484            max_compaction_rows: 5_000_000,             // 5M rows
485            enable_vid_labels_index: true,              // Enable by default
486            object_store: ObjectStoreConfig::default(),
487            index_rebuild: IndexRebuildConfig::default(),
488        }
489    }
490}
491
492/// Cloud storage backend configuration.
493///
494/// Supports Amazon S3, Google Cloud Storage, and Azure Blob Storage.
495/// Each variant contains the credentials and connection parameters for
496/// its respective cloud provider.
497///
498/// # Examples
499///
500/// ```ignore
501/// // Create S3 configuration from environment variables
502/// let config = CloudStorageConfig::s3_from_env("my-bucket");
503///
504/// // Create explicit S3 configuration for LocalStack testing
505/// let config = CloudStorageConfig::S3 {
506///     bucket: "test-bucket".to_string(),
507///     region: Some("us-east-1".to_string()),
508///     endpoint: Some("http://localhost:4566".to_string()),
509///     access_key_id: Some("test".to_string()),
510///     secret_access_key: Some("test".to_string()),
511///     session_token: None,
512///     virtual_hosted_style: false,
513/// };
514/// ```
515#[derive(Clone, Debug)]
516pub enum CloudStorageConfig {
517    /// Amazon S3 storage configuration.
518    S3 {
519        /// S3 bucket name.
520        bucket: String,
521        /// AWS region (e.g., "us-east-1"). Uses AWS_REGION env var if None.
522        region: Option<String>,
523        /// Custom endpoint URL for S3-compatible services (MinIO, LocalStack).
524        endpoint: Option<String>,
525        /// AWS access key ID. Uses AWS_ACCESS_KEY_ID env var if None.
526        access_key_id: Option<String>,
527        /// AWS secret access key. Uses AWS_SECRET_ACCESS_KEY env var if None.
528        secret_access_key: Option<String>,
529        /// AWS session token for temporary credentials.
530        session_token: Option<String>,
531        /// Use virtual-hosted-style requests (bucket.s3.region.amazonaws.com).
532        virtual_hosted_style: bool,
533    },
534    /// Google Cloud Storage configuration.
535    Gcs {
536        /// GCS bucket name.
537        bucket: String,
538        /// Path to service account JSON key file.
539        service_account_path: Option<String>,
540        /// Service account JSON key content (alternative to path).
541        service_account_key: Option<String>,
542    },
543    /// Azure Blob Storage configuration.
544    Azure {
545        /// Azure container name.
546        container: String,
547        /// Azure storage account name.
548        account: String,
549        /// Azure storage account access key.
550        access_key: Option<String>,
551        /// Azure SAS token for limited access.
552        sas_token: Option<String>,
553    },
554}
555
556impl CloudStorageConfig {
557    /// Creates an S3 configuration using environment variables.
558    ///
559    /// Reads credentials from standard AWS environment variables:
560    /// - `AWS_ACCESS_KEY_ID`
561    /// - `AWS_SECRET_ACCESS_KEY`
562    /// - `AWS_SESSION_TOKEN` (optional)
563    /// - `AWS_REGION` or `AWS_DEFAULT_REGION`
564    /// - `AWS_ENDPOINT_URL` (optional, for S3-compatible services)
565    #[must_use]
566    pub fn s3_from_env(bucket: &str) -> Self {
567        Self::S3 {
568            bucket: bucket.to_string(),
569            region: std::env::var("AWS_REGION")
570                .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
571                .ok(),
572            endpoint: std::env::var("AWS_ENDPOINT_URL").ok(),
573            access_key_id: std::env::var("AWS_ACCESS_KEY_ID").ok(),
574            secret_access_key: std::env::var("AWS_SECRET_ACCESS_KEY").ok(),
575            session_token: std::env::var("AWS_SESSION_TOKEN").ok(),
576            virtual_hosted_style: false,
577        }
578    }
579
580    /// Creates a GCS configuration using environment variables.
581    ///
582    /// Reads service account path from `GOOGLE_APPLICATION_CREDENTIALS`.
583    #[must_use]
584    pub fn gcs_from_env(bucket: &str) -> Self {
585        Self::Gcs {
586            bucket: bucket.to_string(),
587            service_account_path: std::env::var("GOOGLE_APPLICATION_CREDENTIALS").ok(),
588            service_account_key: None,
589        }
590    }
591
592    /// Creates an Azure configuration using environment variables.
593    ///
594    /// Reads credentials from Azure environment variables:
595    /// - `AZURE_STORAGE_ACCOUNT`
596    /// - `AZURE_STORAGE_ACCESS_KEY` (optional)
597    /// - `AZURE_STORAGE_SAS_TOKEN` (optional)
598    ///
599    /// # Panics
600    ///
601    /// Panics if `AZURE_STORAGE_ACCOUNT` is not set.
602    #[must_use]
603    pub fn azure_from_env(container: &str) -> Self {
604        Self::Azure {
605            container: container.to_string(),
606            account: std::env::var("AZURE_STORAGE_ACCOUNT")
607                .expect("AZURE_STORAGE_ACCOUNT environment variable required"),
608            access_key: std::env::var("AZURE_STORAGE_ACCESS_KEY").ok(),
609            sas_token: std::env::var("AZURE_STORAGE_SAS_TOKEN").ok(),
610        }
611    }
612
613    /// Returns the bucket/container name for this configuration.
614    #[must_use]
615    pub fn bucket_name(&self) -> &str {
616        match self {
617            Self::S3 { bucket, .. } => bucket,
618            Self::Gcs { bucket, .. } => bucket,
619            Self::Azure { container, .. } => container,
620        }
621    }
622
623    /// Returns a URL-style identifier for this storage location.
624    #[must_use]
625    pub fn to_url(&self) -> String {
626        match self {
627            Self::S3 { bucket, .. } => format!("s3://{bucket}"),
628            Self::Gcs { bucket, .. } => format!("gs://{bucket}"),
629            Self::Azure {
630                container, account, ..
631            } => format!("az://{account}/{container}"),
632        }
633    }
634}
635
636#[cfg(test)]
637mod security_tests {
638    use super::*;
639
640    /// Tests for CWE-22 (Path Traversal) prevention in file sandbox.
641    mod file_sandbox {
642        use super::*;
643
644        #[test]
645        fn test_sandbox_disabled_allows_all_paths() {
646            let config = FileSandboxConfig::default();
647            assert!(!config.enabled);
648            // When disabled, all paths are allowed
649            assert!(config.validate_path("/tmp/test").is_ok());
650        }
651
652        #[test]
653        fn test_sandbox_enabled_with_no_paths_rejects() {
654            let config = FileSandboxConfig {
655                enabled: true,
656                allowed_paths: vec![],
657            };
658            let result = config.validate_path("/tmp/test");
659            assert!(result.is_err());
660            assert!(result.unwrap_err().contains("no allowed paths configured"));
661        }
662
663        #[test]
664        fn test_sandbox_rejects_outside_path() {
665            let config = FileSandboxConfig {
666                enabled: true,
667                allowed_paths: vec![PathBuf::from("/var/lib/uni")],
668            };
669            let result = config.validate_path("/etc/passwd");
670            assert!(result.is_err());
671            assert!(result.unwrap_err().contains("outside allowed sandbox"));
672        }
673
674        #[test]
675        fn test_is_potentially_insecure() {
676            // Disabled is insecure
677            let disabled = FileSandboxConfig::default();
678            assert!(disabled.is_potentially_insecure());
679
680            // Enabled with no paths is insecure
681            let no_paths = FileSandboxConfig {
682                enabled: true,
683                allowed_paths: vec![],
684            };
685            assert!(no_paths.is_potentially_insecure());
686
687            // Enabled with paths is secure
688            let secure = FileSandboxConfig::sandboxed(vec![PathBuf::from("/data")]);
689            assert!(!secure.is_potentially_insecure());
690        }
691
692        #[test]
693        fn test_security_warning_when_disabled() {
694            let disabled = FileSandboxConfig::default();
695            assert!(disabled.security_warning().is_some());
696
697            let enabled = FileSandboxConfig::sandboxed(vec![PathBuf::from("/data")]);
698            assert!(enabled.security_warning().is_none());
699        }
700
701        #[test]
702        fn test_deployment_mode_defaults() {
703            let embedded = FileSandboxConfig::default_for_mode(DeploymentMode::Embedded);
704            assert!(!embedded.enabled);
705
706            let server = FileSandboxConfig::default_for_mode(DeploymentMode::Server);
707            assert!(server.enabled);
708            assert!(!server.allowed_paths.is_empty());
709        }
710    }
711}