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 uncompacted flush generations before triggering compaction (default: 8)
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: 10s)
23    pub check_interval: Duration,
24
25    /// Number of compaction worker threads (default: 1)
26    pub worker_threads: usize,
27
28    /// Number of frozen L0-csr overlay segments that must accumulate before
29    /// `AdjacencyManager::compact` is spawned post-flush (default: 2).
30    ///
31    /// Each frozen segment adds per-read overhead until merged back into the
32    /// Main CSR. Lowering this triggers compaction sooner; higher values
33    /// batch more segments per compaction at the cost of slower reads while
34    /// they accumulate. The default of 2 keeps the read-side overhead
35    /// bounded across a wide range of write rates. See issue #55.
36    pub frozen_segments_compact_threshold: usize,
37}
38
39impl Default for CompactionConfig {
40    fn default() -> Self {
41        Self {
42            enabled: true,
43            max_l1_runs: 8,
44            max_l1_size_bytes: 256 * 1024 * 1024,
45            max_l1_age: Duration::from_secs(3600),
46            check_interval: Duration::from_secs(10),
47            worker_threads: 1,
48            frozen_segments_compact_threshold: 2,
49        }
50    }
51}
52
53/// Configuration for background index rebuilding.
54#[derive(Clone, Debug)]
55pub struct IndexRebuildConfig {
56    /// Maximum number of retry attempts for failed index builds (default: 3).
57    pub max_retries: u32,
58
59    /// Delay between retry attempts (default: 60s).
60    pub retry_delay: Duration,
61
62    /// How often to check for pending index rebuild tasks (default: 5s).
63    pub worker_check_interval: Duration,
64
65    /// Row growth ratio to trigger rebuild (default: 0.5 = 50%). Set 0.0 to disable.
66    pub growth_trigger_ratio: f64,
67
68    /// Max index age before rebuild. `None` disables the time-based trigger.
69    pub max_index_age: Option<Duration>,
70
71    /// Enable post-flush automatic rebuild scheduling (default: false).
72    pub auto_rebuild_enabled: bool,
73}
74
75impl Default for IndexRebuildConfig {
76    fn default() -> Self {
77        Self {
78            max_retries: 3,
79            retry_delay: Duration::from_secs(60),
80            worker_check_interval: Duration::from_secs(5),
81            growth_trigger_ratio: 0.5,
82            max_index_age: None,
83            auto_rebuild_enabled: false,
84        }
85    }
86}
87
88#[derive(Clone, Copy, Debug)]
89pub struct WriteThrottleConfig {
90    /// Uncompacted flush generations to start throttling (default: 16)
91    pub soft_limit: usize,
92
93    /// Uncompacted flush generations to stop writes entirely (default: 32)
94    pub hard_limit: usize,
95
96    /// Base delay when throttling (default: 10ms)
97    pub base_delay: Duration,
98}
99
100impl Default for WriteThrottleConfig {
101    fn default() -> Self {
102        Self {
103            soft_limit: 16,
104            hard_limit: 32,
105            base_delay: Duration::from_millis(10),
106        }
107    }
108}
109
110#[derive(Clone, Debug)]
111pub struct ObjectStoreConfig {
112    pub connect_timeout: Duration,
113    pub read_timeout: Duration,
114    pub write_timeout: Duration,
115    pub max_retries: u32,
116    pub retry_backoff_base: Duration,
117    pub retry_backoff_max: Duration,
118}
119
120impl Default for ObjectStoreConfig {
121    fn default() -> Self {
122        Self {
123            connect_timeout: Duration::from_secs(10),
124            read_timeout: Duration::from_secs(30),
125            write_timeout: Duration::from_secs(60),
126            max_retries: 3,
127            retry_backoff_base: Duration::from_millis(100),
128            retry_backoff_max: Duration::from_secs(10),
129        }
130    }
131}
132
133/// Security configuration for file system operations.
134/// Controls which paths can be accessed by BACKUP, COPY, and EXPORT commands.
135///
136/// Disabled by default for backward compatibility in embedded mode.
137/// MUST be enabled for server mode with untrusted clients.
138#[derive(Clone, Debug, Default)]
139pub struct FileSandboxConfig {
140    /// If true, file operations are restricted to allowed_paths.
141    /// If false, all paths are allowed (NOT RECOMMENDED for server mode).
142    pub enabled: bool,
143
144    /// List of allowed base directories for file operations.
145    /// Paths must be absolute and canonical.
146    /// File operations are only allowed within these directories.
147    pub allowed_paths: Vec<PathBuf>,
148}
149
150/// Deployment mode for the database.
151///
152/// Used to determine appropriate security defaults.
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
154pub enum DeploymentMode {
155    /// Embedded/library mode where the host application controls access.
156    /// File sandbox is disabled by default for backward compatibility.
157    #[default]
158    Embedded,
159    /// Server mode with untrusted clients.
160    /// File sandbox is enabled by default with restricted paths.
161    Server,
162}
163
164/// HTTP server configuration.
165///
166/// Controls CORS, authentication, and other HTTP-related security settings.
167///
168/// # Security
169///
170/// **CWE-942 (Overly Permissive CORS)**, **CWE-306 (Missing Authentication)**:
171/// Production deployments should configure explicit `allowed_origins` and
172/// enable API key authentication.
173#[derive(Clone, Debug)]
174pub struct ServerConfig {
175    /// Allowed CORS origins.
176    ///
177    /// - Empty vector: No CORS headers (most restrictive)
178    /// - `["*"]`: Allow all origins (NOT RECOMMENDED for production)
179    /// - Explicit list: Only allow specified origins (RECOMMENDED)
180    ///
181    /// # Security
182    ///
183    /// **CWE-942**: Using `["*"]` allows any website to make requests to
184    /// your server, potentially exposing sensitive data.
185    pub allowed_origins: Vec<String>,
186
187    /// Optional API key for request authentication.
188    ///
189    /// When set, all API requests must include the header:
190    /// `X-API-Key: <key>`
191    ///
192    /// # Security
193    ///
194    /// **CWE-306**: Without authentication, any client can execute queries.
195    /// Enable this for any deployment accessible beyond localhost.
196    pub api_key: Option<String>,
197
198    /// Whether to require API key for metrics endpoint.
199    ///
200    /// Default: false (metrics are public for observability tooling)
201    pub require_auth_for_metrics: bool,
202}
203
204impl Default for ServerConfig {
205    fn default() -> Self {
206        Self {
207            // Default to localhost-only origin for development safety
208            allowed_origins: vec!["http://localhost:3000".to_string()],
209            api_key: None,
210            require_auth_for_metrics: false,
211        }
212    }
213}
214
215impl ServerConfig {
216    /// Create a permissive config for local development only.
217    ///
218    /// # Security
219    ///
220    /// **WARNING**: Do not use in production. This config allows all CORS origins
221    /// and has no authentication.
222    #[must_use]
223    pub fn development() -> Self {
224        Self {
225            allowed_origins: vec!["*".to_string()],
226            api_key: None,
227            require_auth_for_metrics: false,
228        }
229    }
230
231    /// Create a production config with explicit origins and required API key.
232    ///
233    /// # Panics
234    ///
235    /// Panics if `api_key` is empty.
236    #[must_use]
237    pub fn production(allowed_origins: Vec<String>, api_key: String) -> Self {
238        assert!(
239            !api_key.is_empty(),
240            "API key must not be empty for production"
241        );
242        Self {
243            allowed_origins,
244            api_key: Some(api_key),
245            require_auth_for_metrics: true,
246        }
247    }
248
249    /// Returns a security warning if the config is insecure.
250    pub fn security_warning(&self) -> Option<&'static str> {
251        let allows_all_origins = self.allowed_origins.iter().any(|o| o == "*");
252        if allows_all_origins && self.api_key.is_none() {
253            Some(
254                "Server config has permissive CORS (allow all origins) and no API key. \
255                 This is insecure for production deployments.",
256            )
257        } else if allows_all_origins {
258            Some(
259                "Server config has permissive CORS (allow all origins). \
260                 Consider restricting to specific origins for production.",
261            )
262        } else if self.api_key.is_none() {
263            Some(
264                "Server config has no API key authentication. \
265                 Enable api_key for production deployments.",
266            )
267        } else {
268            None
269        }
270    }
271}
272
273impl FileSandboxConfig {
274    /// Creates a sandboxed config that only allows operations in the specified directories.
275    pub fn sandboxed(paths: Vec<PathBuf>) -> Self {
276        Self {
277            enabled: true,
278            allowed_paths: paths,
279        }
280    }
281
282    /// Creates a config with appropriate defaults for the deployment mode.
283    ///
284    /// # Security
285    ///
286    /// - **Embedded mode**: Sandbox disabled (host application controls access)
287    /// - **Server mode**: Sandbox enabled with default paths `/var/lib/uni/data` and
288    ///   `/var/lib/uni/backups`
289    ///
290    /// **CWE-22 (Path Traversal)**: Server deployments MUST enable the sandbox to
291    /// prevent arbitrary file read/write via BACKUP, COPY, and EXPORT commands.
292    pub fn default_for_mode(mode: DeploymentMode) -> Self {
293        match mode {
294            DeploymentMode::Embedded => Self {
295                enabled: false,
296                allowed_paths: vec![],
297            },
298            DeploymentMode::Server => Self {
299                enabled: true,
300                allowed_paths: vec![
301                    PathBuf::from("/var/lib/uni/data"),
302                    PathBuf::from("/var/lib/uni/backups"),
303                ],
304            },
305        }
306    }
307
308    /// Returns a security warning message if the sandbox is disabled.
309    ///
310    /// Call this at startup to alert administrators about potential security risks.
311    /// Returns `Some(message)` if a warning should be displayed, `None` otherwise.
312    ///
313    /// # Security
314    ///
315    /// **CWE-22 (Path Traversal)**, **CWE-73 (External Control of File Name)**:
316    /// Disabled sandbox allows unrestricted filesystem access for BACKUP, COPY,
317    /// and EXPORT commands, which can lead to:
318    /// - Arbitrary file read/write in server deployments
319    /// - Data exfiltration to attacker-controlled paths
320    /// - Potential privilege escalation via file overwrites
321    ///
322    /// # Example
323    ///
324    /// ```ignore
325    /// if let Some(warning) = config.file_sandbox.security_warning() {
326    ///     tracing::warn!(target: "uni_db::security", "{}", warning);
327    /// }
328    /// ```
329    pub fn security_warning(&self) -> Option<&'static str> {
330        if !self.enabled {
331            Some(
332                "File sandbox is DISABLED. This allows unrestricted filesystem access \
333                 for BACKUP, COPY, and EXPORT commands. Enable sandbox for server \
334                 deployments: file_sandbox.enabled = true",
335            )
336        } else {
337            None
338        }
339    }
340
341    /// Returns whether the sandbox is in a potentially insecure state.
342    ///
343    /// Returns `true` if the sandbox is disabled or enabled with no allowed paths.
344    pub fn is_potentially_insecure(&self) -> bool {
345        !self.enabled || self.allowed_paths.is_empty()
346    }
347
348    /// Validate that a path is within the allowed sandbox.
349    /// Returns Ok(canonical_path) if allowed, Err if not.
350    pub fn validate_path(&self, path: &str) -> Result<PathBuf, String> {
351        if !self.enabled {
352            // Sandbox disabled - allow all paths
353            return Ok(PathBuf::from(path));
354        }
355
356        if self.allowed_paths.is_empty() {
357            return Err("File sandbox is enabled but no allowed paths configured".to_string());
358        }
359
360        // Resolve the path to canonical form to prevent traversal attacks
361        let input_path = Path::new(path);
362
363        // For paths that don't exist yet (e.g., export destinations), we need to
364        // check their parent directory exists and is within allowed paths
365        let canonical = if input_path.exists() {
366            input_path
367                .canonicalize()
368                .map_err(|e| format!("Failed to canonicalize path: {}", e))?
369        } else {
370            // Path doesn't exist - check parent
371            let parent = input_path
372                .parent()
373                .ok_or_else(|| "Invalid path: no parent directory".to_string())?;
374            if !parent.exists() {
375                return Err(format!(
376                    "Parent directory does not exist: {}",
377                    parent.display()
378                ));
379            }
380            let canonical_parent = parent
381                .canonicalize()
382                .map_err(|e| format!("Failed to canonicalize parent: {}", e))?;
383            // Reconstruct with canonical parent + original filename
384            let filename = input_path
385                .file_name()
386                .ok_or_else(|| "Invalid path: no filename".to_string())?;
387            canonical_parent.join(filename)
388        };
389
390        // Check if the canonical path is within any allowed directory
391        for allowed in &self.allowed_paths {
392            // Ensure allowed path is canonical too
393            let canonical_allowed = if allowed.exists() {
394                allowed.canonicalize().unwrap_or_else(|_| allowed.clone())
395            } else {
396                allowed.clone()
397            };
398
399            if canonical.starts_with(&canonical_allowed) {
400                return Ok(canonical);
401            }
402        }
403
404        Err(format!(
405            "Path '{}' is outside allowed sandbox directories. Allowed: {:?}",
406            path, self.allowed_paths
407        ))
408    }
409}
410
411#[derive(Clone, Debug)]
412pub struct UniConfig {
413    /// Maximum adjacency cache size in bytes (default: 1GB)
414    pub cache_size: usize,
415
416    /// Number of worker threads for parallel execution
417    pub parallelism: usize,
418
419    /// Size of each data morsel/batch (number of rows)
420    pub batch_size: usize,
421
422    /// Maximum size of traversal frontier before pruning
423    pub max_frontier_size: usize,
424
425    /// Auto-flush threshold for L0 buffer (default: 10_000 mutations)
426    pub auto_flush_threshold: usize,
427
428    /// Auto-flush interval for L0 buffer (default: 5 seconds).
429    /// Flush triggers if time elapsed AND mutation count >= auto_flush_min_mutations.
430    /// Set to None to disable time-based flush.
431    pub auto_flush_interval: Option<Duration>,
432
433    /// Minimum mutations required before the time-based flush triggers
434    /// (default: 1).
435    ///
436    /// Prevents unnecessary flushes when activity is minimal. Raising this
437    /// (e.g., to 1000) lets small bursts coalesce into one flush — useful
438    /// for benchmark workloads — but for active databases with high write
439    /// rates, raising it reduces flush frequency and lets the active overlay
440    /// grow larger between flushes, which can hurt read latency. Tune with
441    /// `compaction.frozen_segments_compact_threshold` together. See issue
442    /// #55 for the trade-off discussion.
443    pub auto_flush_min_mutations: usize,
444
445    /// Enable write-ahead logging (default: true)
446    pub wal_enabled: bool,
447
448    /// Compaction configuration
449    pub compaction: CompactionConfig,
450
451    /// Write throttling configuration
452    pub throttle: WriteThrottleConfig,
453
454    /// File sandbox configuration for BACKUP/COPY/EXPORT commands.
455    /// MUST be enabled with allowed paths in server mode to prevent arbitrary file access.
456    pub file_sandbox: FileSandboxConfig,
457
458    /// Default query execution timeout (default: 30s)
459    pub query_timeout: Duration,
460
461    /// Maximum wall time a transaction commit may take before it is aborted with
462    /// `CommitTimeout` (default: 5s). This guards against a commit blocking on the
463    /// writer/flush lock, but it also bounds the commit's own compute time — so
464    /// workloads that commit very large transactions in a single shot (bulk-history
465    /// backfills, or unoptimized debug builds) may need to raise it.
466    pub commit_timeout: Duration,
467
468    /// Default maximum memory per query (default: 1GB)
469    pub max_query_memory: usize,
470
471    /// Maximum transaction buffer memory in bytes (default: 1GB).
472    /// Limits memory usage during transactions to prevent OOM.
473    pub max_transaction_memory: usize,
474
475    /// Maximum rows for in-memory compaction (default: 5M, ~725MB at 145 bytes/row).
476    /// Configurable OOM guard to prevent memory exhaustion during compaction.
477    pub max_compaction_rows: usize,
478
479    /// Maximum iterations for recursive CTE evaluation (default: 1000).
480    pub max_recursive_cte_iterations: usize,
481
482    /// Object store resilience configuration
483    pub object_store: ObjectStoreConfig,
484
485    /// Background index rebuild configuration
486    pub index_rebuild: IndexRebuildConfig,
487
488    /// When true, reject writes that reference labels or edge types not declared
489    /// in the schema. Default: false (schemaless mode — any label or edge type
490    /// is accepted and dynamically registered).
491    pub strict_schema: bool,
492
493    /// Enable Lance `MergeInsert` for SET-only flushes (default: false).
494    ///
495    /// When true, `Writer::insert_vertex_partial` records the touched
496    /// property keys into L0 and the flush emits a partial-column source
497    /// to Lance via `MergeInsertBuilder` — skipping the read of (and write
498    /// of) the unchanged columns. Wide-row schemas with vector indexes
499    /// benefit most (~17 ms/row → ~3 ms/row on the issue #72 ingest
500    /// workload). See the Round-11 plan section in
501    /// `plan-and-implement-a-valiant-flame.md`.
502    pub partial_lance_writes: bool,
503
504    /// When true, auto-embedding for vertex writes is deferred from the
505    /// per-row `insert_vertex_*` path to the next L1 flush, where the
506    /// existing `process_embeddings_for_batch` issues one model call for
507    /// the whole flush batch instead of N per-row calls.
508    ///
509    /// Trade-off: in-tx reads of the embedding column on a freshly
510    /// SET/inserted vertex see the OLD storage value (or no value, for
511    /// new vertices) until flush. Existing behavior is identical to
512    /// today's `process_embeddings_impl(target_prop present)` short-circuit
513    /// (writer.rs:2727) — updating only the source text never refreshes
514    /// the embedding mid-tx, deferred or not. Opt-in for workloads that
515    /// don't read embeddings between write and commit.
516    ///
517    /// Default: `false` (preserves bit-for-bit compatibility with
518    /// pre-Phase-B releases).
519    pub defer_embeddings: bool,
520
521    /// Per-fork L1 fragment-count threshold above which a `tracing::warn!`
522    /// fires once per crossing during fork flush. Long-lived heavy-write
523    /// forks accumulate fragments because fork compaction is deferred to
524    /// Phase 5; this surfaces the risk operationally. Default: 256.
525    pub fork_fragment_warn_threshold: usize,
526
527    /// Per-transaction VID/EID reservoir refill size. Each `Transaction`
528    /// pre-reserves this many IDs at a time from the global `IdAllocator`,
529    /// amortizing its `tokio::Mutex` over `N` allocations. Tradeoff:
530    /// larger = fewer global-mutex acquisitions but more wasted IDs on
531    /// short transactions (capped at `batch_size - 1` per tx). u64 ID space
532    /// makes the waste negligible. Default: 16.
533    pub tx_id_reservoir_batch: usize,
534
535    /// When `true`, `check_flush` on the commit path dispatches via the
536    /// async path (`flush_to_l1_async`): rotate L0 under `flush_lock`,
537    /// then spawn the streaming + finalize work on a background task.
538    /// Concurrent committers no longer queue on the flush's long I/O.
539    ///
540    /// When `false` (default for now), `check_flush` calls the original
541    /// synchronous `flush_to_l1` and holds `flush_lock` across the full
542    /// L1-streaming write. This is the kill-switch.
543    pub async_flush_enabled: bool,
544
545    /// Maximum number of L0→L1 flushes that may be in-flight simultaneously
546    /// when `async_flush_enabled` is true. The (N+1)th rotate blocks until
547    /// one of the in-flight flushes finalizes. Bounds WAL retention and
548    /// memory growth. Default: 2.
549    pub max_pending_flushes: usize,
550
551    /// Maximum wall-clock time an async L0→L1 stream phase may run before the
552    /// flush coordinator converts it into a data-safe flush *failure* (issue
553    /// #132). A stalled sparse/multi-vector Lance read-modify-write would
554    /// otherwise never submit its rotate-seq, wedging the finalizer's
555    /// consecutive-seq pipeline and — via back-pressure permit saturation —
556    /// parking every later commit forever on `flush_lock`. On timeout the old
557    /// L0 is retained in `pending_flush`, WAL data is NOT truncated, the
558    /// permit is released, and `expected` advances; recovery is via WAL replay
559    /// / a later retry. Only meaningful when `async_flush_enabled` is true.
560    /// Default: 60s. Override with `UNI_FLUSH_STREAM_TIMEOUT` (seconds).
561    pub flush_stream_timeout: Duration,
562
563    /// Maximum time `drop_fork` will wait for pending async flushes on
564    /// that fork before failing with `PendingFlushTimeout`. Only meaningful
565    /// when `async_flush_enabled` is true. Default: 10s.
566    pub drop_fork_drain_timeout: Duration,
567
568    /// Phase 4a: cap on total fork count (Active + Pending + Tombstoned).
569    /// `None` = unbounded. When set, `Session::fork(name).await` errors
570    /// with `UniError::ForkBudgetExceeded` once the cap is reached.
571    /// Tombstoned forks count because they still hold branch state on
572    /// disk until recovery completes; counting them prevents churn-thrash.
573    ///
574    /// **Production guidance (L11):** the default is `None` (unbounded) to
575    /// avoid surprising existing embedders, but each fork's branches scale
576    /// with schema size and persist until dropped, so unbounded fork churn
577    /// is an on-disk growth risk. Production deployments that create forks
578    /// from untrusted/automated callers SHOULD set an explicit `max_forks`
579    /// (and ideally `fork_default_ttl`).
580    pub max_forks: Option<usize>,
581
582    /// Phase 4a: default TTL applied to forks when the user does not
583    /// supply one via `session.fork(name).ttl(...)`. `None` = no TTL.
584    /// The background sweeper drops forks whose `ttl_expires_at` is in
585    /// the past via `drop_fork_cascade`.
586    pub fork_default_ttl: Option<Duration>,
587
588    /// Phase 4a: how often the background TTL sweeper polls the
589    /// registry for expired forks. Default: 60 seconds.
590    pub fork_sweeper_interval: Duration,
591
592    /// Phase 4a: skip spawning the TTL sweeper. Tests should set this
593    /// to `true` when they want deterministic control over fork
594    /// lifetimes; production should leave it `false`.
595    pub disable_fork_sweeper: bool,
596
597    /// Phase 5a: minimum per-fork row count (per label) before the
598    /// background `IndexRebuildManager` schedules a fork-local index
599    /// build. Below this threshold, fork reads inherit primary's
600    /// indexes through Lance `base_paths`; above it, the planner
601    /// switches to `FusedIndexScan` once the build completes. Default
602    /// 10,000 rows per spec §8.
603    pub fork_index_build_threshold: u64,
604
605    /// Phase 5a-impl Step 7: how often the background fork index
606    /// builder polls active forks for build candidates. Default
607    /// 30 seconds.
608    pub fork_index_builder_interval: Duration,
609
610    /// Phase 5a-impl Step 7: skip spawning the background fork index
611    /// builder. Tests that exercise the manual `Session::build_fork_local_index`
612    /// trigger should set this to `true` so timing isn't dependent on
613    /// the polling cadence.
614    pub disable_fork_index_builder: bool,
615
616    /// Enable Serializable Snapshot Isolation and optimistic concurrency
617    /// control (default: `true`).
618    ///
619    /// When `true`, read-write transactions read from a pinned L0 snapshot,
620    /// track an item-level read/write-set, and validate at commit under
621    /// `flush_lock`: a write-write or read-write conflict against a commit
622    /// landed since the transaction's snapshot aborts with
623    /// `UniError::SerializationConflict`, a duplicate concurrent `MERGE` on a
624    /// unique key aborts with `UniError::ConstraintConflict`, and `FOR UPDATE`
625    /// acquires per-key row locks. Callers should wrap contended writes in
626    /// `Session::transact_with_retry`, which re-runs retriable conflicts.
627    ///
628    /// When `false`, the engine reverts to last-writer-wins: concurrent
629    /// read-modify-write transactions can silently lose updates, concurrent
630    /// `MERGE` can create duplicate unique keys, and `FOR UPDATE` is a no-op
631    /// (a `tracing::warn!` is emitted when a query requests it). Reads run
632    /// against the live L0 with no snapshot pinning. This reproduces the
633    /// pre-SSI behavior bit-for-bit and skips the (near-zero, but non-nil)
634    /// read-set/validation overhead — appropriate only for single-writer
635    /// workloads or callers that guard read-modify-write externally.
636    ///
637    /// Defaults to `true` because silent lost updates are a correctness hazard
638    /// for any concurrent-writer workload.
639    pub ssi_enabled: bool,
640
641    /// Max wall-clock time an `EventualConsistency` trigger's coalescing
642    /// bucket may accumulate before it is flushed as a single batched fire
643    /// (default: 1s). The per-`Uni` deferral tick drains buckets whose
644    /// oldest pending batch is older than this. Lower = fresher fires,
645    /// higher = more coalescing per fire. See `FireMode::EventualConsistency`.
646    pub ec_flush_interval: Duration,
647
648    /// Row count at which an `EventualConsistency` trigger's coalescing
649    /// bucket flushes early, before `ec_flush_interval` elapses (default:
650    /// 10_000). A bucket whose accumulated rows reach this threshold is
651    /// drained on the next tick regardless of age; a bucket that overshoots
652    /// `4 ×` this cap is force-drained inline at enqueue time (back-pressure)
653    /// so an in-flight commit's coalescing buffer can never grow unbounded.
654    pub ec_flush_threshold: usize,
655}
656
657impl Default for UniConfig {
658    fn default() -> Self {
659        let parallelism = thread::available_parallelism().map_or(4, |n| n.get());
660
661        Self {
662            cache_size: 1024 * 1024 * 1024, // 1GB
663            parallelism,
664            batch_size: 1024, // Default morsel size
665            max_frontier_size: 1_000_000,
666            auto_flush_threshold: 10_000,
667            auto_flush_interval: Some(Duration::from_secs(5)),
668            auto_flush_min_mutations: 1,
669            wal_enabled: true,
670            compaction: CompactionConfig::default(),
671            throttle: WriteThrottleConfig::default(),
672            file_sandbox: FileSandboxConfig::default(),
673            query_timeout: Duration::from_secs(30),
674            commit_timeout: Duration::from_secs(5),
675            max_query_memory: 1024 * 1024 * 1024,       // 1GB
676            max_transaction_memory: 1024 * 1024 * 1024, // 1GB
677            max_compaction_rows: 5_000_000,             // 5M rows
678            max_recursive_cte_iterations: 1000,
679            object_store: ObjectStoreConfig::default(),
680            index_rebuild: IndexRebuildConfig::default(),
681            strict_schema: false,
682            partial_lance_writes: false,
683            defer_embeddings: false,
684            fork_fragment_warn_threshold: 256,
685            tx_id_reservoir_batch: 16,
686            // Default ON as of the Item-B-deep-fix landing (per-table
687            // write serialization + Lance Table cache removed + drain
688            // in flush_to_l1). Validated by full UNI_ASYNC_FLUSH=1
689            // cross-crate nextest: 1754/1754 pass.
690            //
691            // `UNI_ASYNC_FLUSH=0` / `=false` / `=no` (case-insensitive)
692            // explicitly DISABLES async flush — useful for bisecting
693            // suspected async-flush regressions and for the sync-only
694            // benchmarks in `flush_pressure.rs`. Unset = default
695            // behavior (true).
696            async_flush_enabled: std::env::var("UNI_ASYNC_FLUSH").map_or(true, |v| {
697                let v = v.to_ascii_lowercase();
698                !(v == "0" || v == "false" || v == "no")
699            }),
700            max_pending_flushes: 2,
701            // Bound a stalled async flush stream so a lost-wakeup in the
702            // sparse/multivec Lance RMW can't permanently wedge the pipeline
703            // (issue #132). 60s ≈ 200× a healthy flush, so it never kills a
704            // legitimately-slow flush, yet recovers a true stall in a minute.
705            flush_stream_timeout: std::env::var("UNI_FLUSH_STREAM_TIMEOUT")
706                .ok()
707                .and_then(|v| v.parse::<u64>().ok())
708                .map(Duration::from_secs)
709                .unwrap_or(Duration::from_secs(60)),
710            drop_fork_drain_timeout: Duration::from_secs(10),
711            max_forks: None,
712            fork_default_ttl: None,
713            fork_sweeper_interval: Duration::from_secs(60),
714            disable_fork_sweeper: false,
715            fork_index_build_threshold: 10_000,
716            fork_index_builder_interval: Duration::from_secs(30),
717            disable_fork_index_builder: false,
718            // Correctness-first default: SSI/OCC on. See the field docs for
719            // the behavioral contract and the migration note (concurrent
720            // writers now observe aborts instead of silent lost updates;
721            // wrap them in `Session::transact_with_retry`).
722            ssi_enabled: true,
723            // EventualConsistency coalescing (WS-E): default to a 1s window
724            // and a 10k-row early-flush threshold, mirroring the L0
725            // auto-flush knobs so operators reason about them together.
726            ec_flush_interval: Duration::from_secs(1),
727            ec_flush_threshold: 10_000,
728        }
729    }
730}
731
732/// Cloud storage backend configuration.
733///
734/// Supports Amazon S3, Google Cloud Storage, and Azure Blob Storage.
735/// Each variant contains the credentials and connection parameters for
736/// its respective cloud provider.
737///
738/// # Examples
739///
740/// ```ignore
741/// // Create S3 configuration from environment variables
742/// let config = CloudStorageConfig::s3_from_env("my-bucket");
743///
744/// // Create explicit S3 configuration for LocalStack testing
745/// let config = CloudStorageConfig::S3 {
746///     bucket: "test-bucket".to_string(),
747///     region: Some("us-east-1".to_string()),
748///     endpoint: Some("http://localhost:4566".to_string()),
749///     access_key_id: Some("test".to_string()),
750///     secret_access_key: Some("test".to_string()),
751///     session_token: None,
752///     virtual_hosted_style: false,
753/// };
754/// ```
755#[derive(Clone, Debug)]
756pub enum CloudStorageConfig {
757    /// Amazon S3 storage configuration.
758    S3 {
759        /// S3 bucket name.
760        bucket: String,
761        /// AWS region (e.g., "us-east-1"). Uses AWS_REGION env var if None.
762        region: Option<String>,
763        /// Custom endpoint URL for S3-compatible services (MinIO, LocalStack).
764        endpoint: Option<String>,
765        /// AWS access key ID. Uses AWS_ACCESS_KEY_ID env var if None.
766        access_key_id: Option<String>,
767        /// AWS secret access key. Uses AWS_SECRET_ACCESS_KEY env var if None.
768        secret_access_key: Option<String>,
769        /// AWS session token for temporary credentials.
770        session_token: Option<String>,
771        /// Use virtual-hosted-style requests (bucket.s3.region.amazonaws.com).
772        virtual_hosted_style: bool,
773    },
774    /// Google Cloud Storage configuration.
775    Gcs {
776        /// GCS bucket name.
777        bucket: String,
778        /// Path to service account JSON key file.
779        service_account_path: Option<String>,
780        /// Service account JSON key content (alternative to path).
781        service_account_key: Option<String>,
782    },
783    /// Azure Blob Storage configuration.
784    Azure {
785        /// Azure container name.
786        container: String,
787        /// Azure storage account name.
788        account: String,
789        /// Azure storage account access key.
790        access_key: Option<String>,
791        /// Azure SAS token for limited access.
792        sas_token: Option<String>,
793    },
794}
795
796impl CloudStorageConfig {
797    /// Creates an S3 configuration using environment variables.
798    ///
799    /// Reads credentials from standard AWS environment variables:
800    /// - `AWS_ACCESS_KEY_ID`
801    /// - `AWS_SECRET_ACCESS_KEY`
802    /// - `AWS_SESSION_TOKEN` (optional)
803    /// - `AWS_REGION` or `AWS_DEFAULT_REGION`
804    /// - `AWS_ENDPOINT_URL` (optional, for S3-compatible services)
805    #[must_use]
806    pub fn s3_from_env(bucket: &str) -> Self {
807        Self::S3 {
808            bucket: bucket.to_string(),
809            region: std::env::var("AWS_REGION")
810                .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
811                .ok(),
812            endpoint: std::env::var("AWS_ENDPOINT_URL").ok(),
813            access_key_id: std::env::var("AWS_ACCESS_KEY_ID").ok(),
814            secret_access_key: std::env::var("AWS_SECRET_ACCESS_KEY").ok(),
815            session_token: std::env::var("AWS_SESSION_TOKEN").ok(),
816            virtual_hosted_style: false,
817        }
818    }
819
820    /// Creates a GCS configuration using environment variables.
821    ///
822    /// Reads service account path from `GOOGLE_APPLICATION_CREDENTIALS`.
823    #[must_use]
824    pub fn gcs_from_env(bucket: &str) -> Self {
825        Self::Gcs {
826            bucket: bucket.to_string(),
827            service_account_path: std::env::var("GOOGLE_APPLICATION_CREDENTIALS").ok(),
828            service_account_key: None,
829        }
830    }
831
832    /// Creates an Azure configuration using environment variables.
833    ///
834    /// Reads credentials from Azure environment variables:
835    /// - `AZURE_STORAGE_ACCOUNT`
836    /// - `AZURE_STORAGE_ACCESS_KEY` (optional)
837    /// - `AZURE_STORAGE_SAS_TOKEN` (optional)
838    ///
839    /// # Panics
840    ///
841    /// Panics if `AZURE_STORAGE_ACCOUNT` is not set.
842    #[must_use]
843    pub fn azure_from_env(container: &str) -> Self {
844        Self::Azure {
845            container: container.to_string(),
846            account: std::env::var("AZURE_STORAGE_ACCOUNT")
847                .expect("AZURE_STORAGE_ACCOUNT environment variable required"),
848            access_key: std::env::var("AZURE_STORAGE_ACCESS_KEY").ok(),
849            sas_token: std::env::var("AZURE_STORAGE_SAS_TOKEN").ok(),
850        }
851    }
852
853    /// Returns the bucket/container name for this configuration.
854    #[must_use]
855    pub fn bucket_name(&self) -> &str {
856        match self {
857            Self::S3 { bucket, .. } => bucket,
858            Self::Gcs { bucket, .. } => bucket,
859            Self::Azure { container, .. } => container,
860        }
861    }
862
863    /// Returns a URL-style identifier for this storage location.
864    #[must_use]
865    pub fn to_url(&self) -> String {
866        match self {
867            Self::S3 { bucket, .. } => format!("s3://{bucket}"),
868            Self::Gcs { bucket, .. } => format!("gs://{bucket}"),
869            Self::Azure {
870                container, account, ..
871            } => format!("az://{account}/{container}"),
872        }
873    }
874}
875
876#[cfg(test)]
877mod security_tests {
878    use super::*;
879
880    /// Tests for CWE-22 (Path Traversal) prevention in file sandbox.
881    mod file_sandbox {
882        use super::*;
883
884        #[test]
885        fn test_sandbox_disabled_allows_all_paths() {
886            let config = FileSandboxConfig::default();
887            assert!(!config.enabled);
888            // When disabled, all paths are allowed
889            assert!(config.validate_path("/tmp/test").is_ok());
890        }
891
892        #[test]
893        fn test_sandbox_enabled_with_no_paths_rejects() {
894            let config = FileSandboxConfig {
895                enabled: true,
896                allowed_paths: vec![],
897            };
898            let result = config.validate_path("/tmp/test");
899            assert!(result.is_err());
900            assert!(result.unwrap_err().contains("no allowed paths configured"));
901        }
902
903        #[test]
904        fn test_sandbox_rejects_outside_path() {
905            let config = FileSandboxConfig {
906                enabled: true,
907                allowed_paths: vec![PathBuf::from("/var/lib/uni")],
908            };
909            let result = config.validate_path("/etc/passwd");
910            assert!(result.is_err());
911            assert!(result.unwrap_err().contains("outside allowed sandbox"));
912        }
913
914        #[test]
915        fn test_is_potentially_insecure() {
916            // Disabled is insecure
917            let disabled = FileSandboxConfig::default();
918            assert!(disabled.is_potentially_insecure());
919
920            // Enabled with no paths is insecure
921            let no_paths = FileSandboxConfig {
922                enabled: true,
923                allowed_paths: vec![],
924            };
925            assert!(no_paths.is_potentially_insecure());
926
927            // Enabled with paths is secure
928            let secure = FileSandboxConfig::sandboxed(vec![PathBuf::from("/data")]);
929            assert!(!secure.is_potentially_insecure());
930        }
931
932        #[test]
933        fn test_security_warning_when_disabled() {
934            let disabled = FileSandboxConfig::default();
935            assert!(disabled.security_warning().is_some());
936
937            let enabled = FileSandboxConfig::sandboxed(vec![PathBuf::from("/data")]);
938            assert!(enabled.security_warning().is_none());
939        }
940
941        #[test]
942        fn test_deployment_mode_defaults() {
943            let embedded = FileSandboxConfig::default_for_mode(DeploymentMode::Embedded);
944            assert!(!embedded.enabled);
945
946            let server = FileSandboxConfig::default_for_mode(DeploymentMode::Server);
947            assert!(server.enabled);
948            assert!(!server.allowed_paths.is_empty());
949        }
950    }
951}