Skip to main content

type_bridge_server/
config.rs

1use std::ffi::OsString;
2use std::fmt;
3use std::io::{Read, Seek, SeekFrom};
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7#[cfg(unix)]
8use std::os::unix::fs::OpenOptionsExt as _;
9#[cfg(windows)]
10use std::os::windows::fs::OpenOptionsExt as _;
11
12#[cfg(unix)]
13use cap_fs_ext::OpenOptionsSyncExt as _;
14use cap_fs_ext::{
15    DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _, OpenOptionsMaybeDirExt as _,
16};
17use cap_std::ambient_authority;
18#[cfg(windows)]
19use cap_std::fs::OpenOptionsExt as _;
20use cap_std::fs::{Dir, OpenOptions};
21use serde::Deserialize;
22use type_bridge_core_lib::version as core_version;
23
24const MAX_TLS_MATERIAL_BYTES: u64 = 1024 * 1024;
25const MAX_SERVER_CONFIG_BYTES: u64 = 1024 * 1024;
26#[cfg(feature = "v2-query")]
27const MAX_DECLARED_SCHEMA_BYTES: usize = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
28
29#[derive(Clone, Copy)]
30enum RuntimeConfigParseErrorKind {
31    Syntax,
32    ValueShape,
33    UnknownSecurityKey,
34}
35
36struct RuntimeConfigParseError {
37    kind: RuntimeConfigParseErrorKind,
38    location: Option<(usize, usize)>,
39}
40
41impl RuntimeConfigParseError {
42    fn from_toml(kind: RuntimeConfigParseErrorKind, content: &str, error: toml::de::Error) -> Self {
43        // Extract only numeric source coordinates. The TOML error owns the
44        // original document and its Display text may reproduce a complete
45        // secret-bearing line, so neither is retained as a source.
46        let location = error
47            .span()
48            .and_then(|span| source_line_column(content, span.start));
49        Self { kind, location }
50    }
51
52    const fn unknown_security_key() -> Self {
53        Self {
54            kind: RuntimeConfigParseErrorKind::UnknownSecurityKey,
55            location: None,
56        }
57    }
58}
59
60impl fmt::Display for RuntimeConfigParseError {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        let reason = match self.kind {
63            RuntimeConfigParseErrorKind::Syntax => "TOML syntax",
64            RuntimeConfigParseErrorKind::ValueShape => "value shape",
65            RuntimeConfigParseErrorKind::UnknownSecurityKey => "unknown security-sensitive key",
66        };
67        write!(formatter, "server configuration is invalid ({reason})")?;
68        if let Some((line, column)) = self.location {
69            write!(formatter, " at line {line}, column {column}")?;
70        }
71        Ok(())
72    }
73}
74
75impl fmt::Debug for RuntimeConfigParseError {
76    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77        fmt::Display::fmt(self, formatter)
78    }
79}
80
81impl std::error::Error for RuntimeConfigParseError {}
82
83fn source_line_column(content: &str, byte_offset: usize) -> Option<(usize, usize)> {
84    if byte_offset > content.len() || !content.is_char_boundary(byte_offset) {
85        return None;
86    }
87    let prefix = &content[..byte_offset];
88    let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
89    let column = prefix
90        .rsplit_once('\n')
91        .map_or(prefix, |(_, tail)| tail)
92        .chars()
93        .count()
94        + 1;
95    Some((line, column))
96}
97
98#[derive(Debug, Deserialize)]
99pub struct ServerConfig {
100    pub server: ServerSection,
101    pub typedb: TypeDBSection,
102    #[serde(default)]
103    pub schema: SchemaSection,
104    #[serde(default)]
105    pub interceptors: InterceptorsSection,
106    #[serde(default)]
107    pub logging: LoggingSection,
108}
109
110/// Standalone-server configuration with additive secure and V2 settings.
111///
112/// [`ServerConfig`] remains the released plaintext configuration surface so
113/// downstream exhaustive struct literals and patterns keep compiling. The
114/// standalone binary parses this projection instead; its private wire types
115/// accept the same TOML tables while keeping new fields out of the released
116/// structs.
117pub struct RuntimeServerConfig {
118    pub server: ServerSection,
119    pub typedb: SecureTypeDBSection,
120    pub schema: SchemaSection,
121    pub interceptors: InterceptorsSection,
122    pub logging: LoggingSection,
123    pub inbound_tls: Option<InboundTlsSection>,
124    pub v2: V2Section,
125}
126
127/// TypeDB connection settings for the standalone server's secure entry point.
128///
129/// The credential-bearing V1 projection is deliberately not publicly
130/// reachable from a loaded runtime config:
131///
132/// ```compile_fail
133/// use type_bridge_server::config::SecureTypeDBSection;
134///
135/// fn accidentally_log_raw_connection(config: &SecureTypeDBSection) {
136///     let _ = format!("{:?}", config.connection);
137/// }
138/// ```
139pub struct SecureTypeDBSection {
140    /// Released connection fields, preserved as one exact V1 projection.
141    pub(crate) connection: TypeDBSection,
142    /// Validated transport mode used by both HTTP discovery and gRPC.
143    pub tls_mode: OutboundTlsMode,
144    // Relative custom roots are captured while the configuration directory
145    // handle is retained. Transport preparation consumes these exact bytes
146    // instead of reopening the diagnostic path below.
147    #[cfg_attr(not(feature = "typedb"), allow(dead_code))]
148    pub(crate) custom_root_ca_snapshot: Option<CapturedConfiguredMaterial>,
149    // Runtime-only storage avoids changing the public V2 configuration
150    // projection while binding the declared schema to this exact load.
151    #[cfg(feature = "v2-query")]
152    pub(crate) v2_declared_schema_snapshot: Option<CapturedConfiguredMaterial>,
153}
154
155impl fmt::Debug for RuntimeServerConfig {
156    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
157        formatter
158            .debug_struct("RuntimeServerConfig")
159            .field("server", &self.server)
160            .field("typedb", &self.typedb)
161            .field("schema", &self.schema)
162            .field("interceptors", &self.interceptors)
163            .field("logging", &self.logging)
164            .field("inbound_tls", &self.inbound_tls)
165            .field("v2", &self.v2)
166            .finish()
167    }
168}
169
170impl fmt::Debug for SecureTypeDBSection {
171    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172        formatter
173            .debug_struct("SecureTypeDBSection")
174            .field("address", &"[REDACTED]")
175            .field("database", &self.connection.database)
176            .field("username", &"[REDACTED]")
177            .field("password", &"[REDACTED]")
178            .field("http_port", &self.connection.http_port)
179            .field("server_version", &self.connection.server_version)
180            .field("tls_mode", &self.tls_mode)
181            .field("custom_root_ca_snapshot", &self.custom_root_ca_snapshot)
182            .finish()
183    }
184}
185
186impl SecureTypeDBSection {
187    /// Construct secure connection settings from an independently owned TLS
188    /// policy. Custom-root paths are captured during transport preparation.
189    #[must_use]
190    pub fn new(connection: TypeDBSection, tls_mode: OutboundTlsMode) -> Self {
191        Self {
192            connection,
193            tls_mode,
194            custom_root_ca_snapshot: None,
195            #[cfg(feature = "v2-query")]
196            v2_declared_schema_snapshot: None,
197        }
198    }
199
200    /// Return the non-secret database name selected by this runtime config.
201    #[must_use]
202    pub fn database(&self) -> &str {
203        &self.connection.database
204    }
205}
206
207/// Authority mode for the standalone V2 query executor.
208#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
209#[serde(rename_all = "snake_case")]
210pub enum V2AuthorityMode {
211    /// Require the complete managed migration-control partition and singleton.
212    #[default]
213    Managed,
214    /// Require no V2 or legacy migration controls and bind to this database.
215    QueryOnly,
216}
217
218/// Optional versioned V2 query surface served beside the V1 routes.
219///
220/// Requires a binary built with `--features v2-query`; enabling it on a
221/// build without the feature aborts startup instead of silently serving
222/// 404s.
223#[derive(Debug, Default, Deserialize)]
224pub struct V2Section {
225    /// Serve `/v2/query` and `/v2/capabilities`.
226    #[serde(default)]
227    pub enabled: bool,
228    /// Path to canonical declared-schema bytes (a generated
229    /// `declared-schema.json`) V2 plans validate against.
230    #[serde(default)]
231    pub declared_schema_file: String,
232    /// Exclusive managed scope identity plans bind against.
233    #[serde(default)]
234    pub scope: String,
235    /// Semantic profile identifier, e.g. `typedb-3.12.1/v1`.
236    #[serde(default)]
237    pub profile: String,
238    /// Exact authority contract; defaults to `managed`.
239    #[serde(default)]
240    pub authority_mode: V2AuthorityMode,
241}
242
243#[derive(Debug, Deserialize)]
244pub struct ServerSection {
245    #[serde(default = "default_host")]
246    pub host: String,
247    #[serde(default = "default_port")]
248    pub port: u16,
249}
250
251/// Inbound server identity loaded and validated before binding a listener.
252#[derive(Clone, Debug, Deserialize)]
253#[serde(deny_unknown_fields)]
254pub struct InboundTlsSection {
255    #[serde(rename = "cert-path")]
256    pub cert_path: PathBuf,
257    #[serde(rename = "key-path")]
258    pub key_path: PathBuf,
259    #[serde(skip)]
260    prepared: Option<PreparedInboundTlsMaterial>,
261}
262
263#[derive(Clone)]
264pub(crate) struct CapturedConfiguredMaterial {
265    pub(crate) path: PathBuf,
266    pub(crate) bytes: Arc<[u8]>,
267}
268
269impl fmt::Debug for CapturedConfiguredMaterial {
270    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271        formatter
272            .debug_struct("CapturedConfiguredMaterial")
273            .field("path", &self.path)
274            .field("bytes", &self.bytes.len())
275            .finish()
276    }
277}
278
279#[derive(Clone, Default)]
280struct PreparedInboundTlsMaterial {
281    certificate: Option<CapturedConfiguredMaterial>,
282    private_key: Option<CapturedConfiguredMaterial>,
283}
284
285impl fmt::Debug for PreparedInboundTlsMaterial {
286    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
287        formatter
288            .debug_struct("PreparedInboundTlsMaterial")
289            .field(
290                "certificate_bytes",
291                &self
292                    .certificate
293                    .as_ref()
294                    .map(|material| material.bytes.len()),
295            )
296            .field(
297                "private_key_bytes",
298                &self
299                    .private_key
300                    .as_ref()
301                    .map(|material| material.bytes.len()),
302            )
303            .finish()
304    }
305}
306
307impl InboundTlsSection {
308    /// Construct an inbound identity whose absolute or caller-owned paths are
309    /// read when the transport identity is loaded.
310    #[must_use]
311    pub fn from_paths(cert_path: PathBuf, key_path: PathBuf) -> Self {
312        Self {
313            cert_path,
314            key_path,
315            prepared: None,
316        }
317    }
318}
319
320#[cfg(feature = "axum-transport")]
321impl InboundTlsSection {
322    /// Load and cross-check the certificate chain and private key before bind.
323    pub async fn load(
324        &self,
325    ) -> Result<axum_server::tls_rustls::RustlsConfig, Box<dyn std::error::Error>> {
326        fn read_bounded(path: &Path, field: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
327            use std::path::Component;
328
329            use cap_fs_ext::{
330                DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _,
331                OpenOptionsMaybeDirExt as _, OpenOptionsSyncExt as _,
332            };
333
334            if !path.is_absolute() {
335                return Err(format!("{field} must be an absolute resolved path").into());
336            }
337            let anchor = path
338                .ancestors()
339                .last()
340                .ok_or_else(|| format!("{field} has no filesystem anchor"))?;
341            let relative = path
342                .strip_prefix(anchor)
343                .map_err(|_| format!("{field} is not beneath its filesystem anchor"))?;
344            let components = relative
345                .components()
346                .map(|component| match component {
347                    Component::Normal(name) => Ok(name),
348                    _ => Err(format!("{field} contains an invalid path component")),
349                })
350                .collect::<Result<Vec<_>, _>>()?;
351            let (name, parents) = components
352                .split_last()
353                .ok_or_else(|| format!("{field} has no file name"))?;
354            let mut directory =
355                cap_std::fs::Dir::open_ambient_dir(anchor, cap_std::ambient_authority())
356                    .map_err(|error| format!("cannot read {field}: {error}"))?;
357            for parent in parents {
358                directory = directory
359                    .open_dir_nofollow(parent)
360                    .map_err(|error| format!("cannot read {field}: {error}"))?;
361            }
362            let mut options = cap_std::fs::OpenOptions::new();
363            options.read(true).follow(FollowSymlinks::No).nonblock(true);
364            // A directory must open (Windows needs backup semantics for
365            // that) so the regular-file classification below comes from the
366            // opened handle's metadata, exactly as it does on Unix.
367            options.maybe_dir(true);
368            let mut file = directory
369                .open_with(name, &options)
370                .map(cap_std::fs::File::into_std)
371                .map_err(|error| format!("cannot read {field}: {error}"))?;
372            let metadata = file
373                .metadata()
374                .map_err(|error| format!("cannot inspect {field}: {error}"))?;
375            if !metadata.is_file() {
376                return Err(format!("{field} must name a regular file").into());
377            }
378            let mut bytes = Vec::new();
379            (&mut file)
380                .take(MAX_TLS_MATERIAL_BYTES + 1)
381                .read_to_end(&mut bytes)
382                .map_err(|error| format!("cannot read {field}: {error}"))?;
383            if bytes.is_empty()
384                || u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_TLS_MATERIAL_BYTES
385            {
386                return Err(
387                    format!("{field} must be a non-empty file no larger than 1 MiB").into(),
388                );
389            }
390            file.seek(SeekFrom::Start(0))
391                .map_err(|error| format!("cannot reread {field}: {error}"))?;
392            let mut verification_bytes = Vec::new();
393            (&mut file)
394                .take(MAX_TLS_MATERIAL_BYTES + 1)
395                .read_to_end(&mut verification_bytes)
396                .map_err(|error| format!("cannot reread {field}: {error}"))?;
397            let after = file
398                .metadata()
399                .map_err(|error| format!("cannot inspect {field}: {error}"))?;
400            let timestamps_match = match (metadata.modified(), after.modified()) {
401                (Ok(before), Ok(after)) => before == after,
402                (Err(_), Err(_)) => true,
403                _ => false,
404            };
405            if metadata.len() != after.len()
406                || metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
407                || bytes != verification_bytes
408                || !timestamps_match
409            {
410                return Err(format!("{field} changed while it was being read").into());
411            }
412            Ok(bytes)
413        }
414
415        fn captured_bytes(
416            material: Option<&CapturedConfiguredMaterial>,
417            current_path: &Path,
418            field: &str,
419        ) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
420            let Some(material) = material else {
421                return Ok(None);
422            };
423            if material.path != current_path {
424                return Err(format!(
425                    "{field} changed after its relative material was captured; reload the configuration"
426                )
427                .into());
428            }
429            Ok(Some(material.bytes.to_vec()))
430        }
431
432        let certificate = match captured_bytes(
433            self.prepared
434                .as_ref()
435                .and_then(|prepared| prepared.certificate.as_ref()),
436            &self.cert_path,
437            "server.tls.cert-path",
438        )? {
439            Some(bytes) => bytes,
440            None => read_bounded(&self.cert_path, "server.tls.cert-path")?,
441        };
442        let private_key = match captured_bytes(
443            self.prepared
444                .as_ref()
445                .and_then(|prepared| prepared.private_key.as_ref()),
446            &self.key_path,
447            "server.tls.key-path",
448        )? {
449            Some(bytes) => bytes,
450            None => read_bounded(&self.key_path, "server.tls.key-path")?,
451        };
452        axum_server::tls_rustls::RustlsConfig::from_pem(certificate, private_key)
453            .await
454            .map_err(|error| format!("invalid server.tls identity: {error}").into())
455    }
456}
457
458#[derive(Deserialize)]
459pub struct TypeDBSection {
460    pub address: String,
461    pub database: String,
462    #[serde(default = "default_username")]
463    pub username: String,
464    #[serde(default = "default_password")]
465    pub password: String,
466    /// Port of the TypeDB HTTP API on the same host as `address`; the
467    /// connect-time version gate probes `/v1/version` here.
468    #[serde(default = "default_http_port")]
469    pub http_port: u16,
470    /// Exact TypeDB server version to validate when the HTTP API is disabled
471    /// or unreachable. When set, the connect-time gate skips HTTP probing.
472    #[serde(default)]
473    pub server_version: Option<String>,
474}
475
476impl fmt::Debug for TypeDBSection {
477    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
478        formatter
479            .debug_struct("TypeDBSection")
480            .field("address", &"[REDACTED]")
481            .field("database", &self.database)
482            .field("username", &"[REDACTED]")
483            .field("password", &"[REDACTED]")
484            .field("http_port", &self.http_port)
485            .field("server_version", &self.server_version)
486            .finish()
487    }
488}
489
490/// Validated outbound TLS policy independent of any driver implementation.
491#[derive(Clone, Debug, Eq, PartialEq)]
492pub enum OutboundTlsMode {
493    Disabled,
494    NativeRoots,
495    CustomRootCa(PathBuf),
496}
497
498#[derive(Debug, Deserialize)]
499struct RuntimeServerConfigWire {
500    server: RuntimeServerSectionWire,
501    typedb: RuntimeTypeDBSectionWire,
502    #[serde(default)]
503    schema: RuntimeSchemaSectionWire,
504    #[serde(default)]
505    interceptors: RuntimeInterceptorsSectionWire,
506    #[serde(default)]
507    logging: RuntimeLoggingSectionWire,
508    #[serde(default)]
509    v2: RuntimeV2SectionWire,
510}
511
512#[derive(Debug, Deserialize)]
513struct RuntimeServerSectionWire {
514    #[serde(default = "default_host")]
515    host: String,
516    #[serde(default = "default_port")]
517    port: u16,
518    #[serde(default)]
519    tls: Option<InboundTlsSection>,
520}
521
522#[derive(Deserialize)]
523struct RuntimeTypeDBSectionWire {
524    address: String,
525    database: String,
526    #[serde(default = "default_username")]
527    username: String,
528    #[serde(default = "default_password")]
529    password: String,
530    #[serde(default = "default_http_port")]
531    http_port: u16,
532    #[serde(default)]
533    server_version: Option<String>,
534    #[serde(default)]
535    tls: Option<bool>,
536    #[serde(default, rename = "tls-root-ca")]
537    tls_root_ca: Option<PathBuf>,
538}
539
540impl fmt::Debug for RuntimeTypeDBSectionWire {
541    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542        formatter
543            .debug_struct("RuntimeTypeDBSectionWire")
544            .field("address", &"[REDACTED]")
545            .field("database", &self.database)
546            .field("username", &"[REDACTED]")
547            .field("password", &"[REDACTED]")
548            .field("http_port", &self.http_port)
549            .field("server_version", &self.server_version)
550            .field("tls", &self.tls)
551            .field("tls_root_ca", &self.tls_root_ca)
552            .finish()
553    }
554}
555
556#[derive(Debug, Default, Deserialize)]
557struct RuntimeSchemaSectionWire {
558    #[serde(default)]
559    source_file: String,
560}
561
562#[derive(Debug, Default, Deserialize)]
563struct RuntimeInterceptorsSectionWire {
564    #[serde(default)]
565    enabled: Vec<String>,
566    #[serde(default, rename = "audit-log")]
567    audit_log: Option<RuntimeAuditLogConfigWire>,
568}
569
570#[derive(Debug, Clone, Deserialize)]
571struct RuntimeAuditLogConfigWire {
572    #[serde(default = "default_audit_output")]
573    output: String,
574    #[serde(default)]
575    file_path: String,
576}
577
578#[derive(Debug, Deserialize)]
579struct RuntimeLoggingSectionWire {
580    #[serde(default = "default_log_level")]
581    level: String,
582    #[serde(default = "default_log_format")]
583    format: String,
584}
585
586impl Default for RuntimeLoggingSectionWire {
587    fn default() -> Self {
588        Self {
589            level: default_log_level(),
590            format: default_log_format(),
591        }
592    }
593}
594
595#[derive(Debug, Default, Deserialize)]
596#[serde(deny_unknown_fields)]
597struct RuntimeV2SectionWire {
598    #[serde(default)]
599    enabled: bool,
600    #[serde(default)]
601    declared_schema_file: String,
602    #[serde(default)]
603    scope: String,
604    #[serde(default)]
605    profile: String,
606    #[serde(default)]
607    authority_mode: V2AuthorityMode,
608}
609
610impl From<RuntimeSchemaSectionWire> for SchemaSection {
611    fn from(wire: RuntimeSchemaSectionWire) -> Self {
612        Self {
613            source_file: wire.source_file,
614        }
615    }
616}
617
618impl From<RuntimeInterceptorsSectionWire> for InterceptorsSection {
619    fn from(wire: RuntimeInterceptorsSectionWire) -> Self {
620        Self {
621            enabled: wire.enabled,
622            audit_log: wire.audit_log.map(Into::into),
623        }
624    }
625}
626
627impl From<RuntimeAuditLogConfigWire> for AuditLogConfig {
628    fn from(wire: RuntimeAuditLogConfigWire) -> Self {
629        Self {
630            output: wire.output,
631            file_path: wire.file_path,
632        }
633    }
634}
635
636impl From<RuntimeLoggingSectionWire> for LoggingSection {
637    fn from(wire: RuntimeLoggingSectionWire) -> Self {
638        Self {
639            level: wire.level,
640            format: wire.format,
641        }
642    }
643}
644
645impl From<RuntimeV2SectionWire> for V2Section {
646    fn from(wire: RuntimeV2SectionWire) -> Self {
647        Self {
648            enabled: wire.enabled,
649            declared_schema_file: wire.declared_schema_file,
650            scope: wire.scope,
651            profile: wire.profile,
652            authority_mode: wire.authority_mode,
653        }
654    }
655}
656
657#[derive(Debug, Default, Deserialize)]
658pub struct SchemaSection {
659    #[serde(default)]
660    pub source_file: String,
661}
662
663#[derive(Debug, Default, Deserialize)]
664pub struct InterceptorsSection {
665    #[serde(default)]
666    pub enabled: Vec<String>,
667    #[serde(default, rename = "audit-log")]
668    pub audit_log: Option<AuditLogConfig>,
669}
670
671#[derive(Debug, Clone, Deserialize)]
672pub struct AuditLogConfig {
673    #[serde(default = "default_audit_output")]
674    pub output: String,
675    #[serde(default)]
676    pub file_path: String,
677}
678
679#[derive(Debug, Deserialize)]
680pub struct LoggingSection {
681    #[serde(default = "default_log_level")]
682    pub level: String,
683    #[serde(default = "default_log_format")]
684    pub format: String,
685}
686
687impl Default for LoggingSection {
688    fn default() -> Self {
689        Self {
690            level: default_log_level(),
691            format: default_log_format(),
692        }
693    }
694}
695
696fn default_host() -> String {
697    "0.0.0.0".to_string()
698}
699
700fn default_port() -> u16 {
701    8080
702}
703
704fn default_http_port() -> u16 {
705    core_version::DEFAULT_HTTP_PORT
706}
707
708fn default_username() -> String {
709    "admin".to_string()
710}
711
712fn default_password() -> String {
713    "password".to_string()
714}
715
716fn default_log_level() -> String {
717    "info".to_string()
718}
719
720fn default_log_format() -> String {
721    "json".to_string()
722}
723
724fn default_audit_output() -> String {
725    "stdout".to_string()
726}
727
728impl ServerConfig {
729    /// Load the released plaintext server configuration.
730    pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
731        Self::from_file_with_env(path, |name| std::env::var(name).ok())
732    }
733
734    fn from_file_with_env<F>(path: &str, get_env: F) -> Result<Self, Box<dyn std::error::Error>>
735    where
736        F: FnMut(&str) -> Option<String>,
737    {
738        let content = std::fs::read_to_string(path)?;
739        let mut config: ServerConfig = toml::from_str(&content)?;
740        config.apply_env_overrides_from(get_env)?;
741        Ok(config)
742    }
743
744    fn apply_env_overrides_from<F>(
745        &mut self,
746        mut get_env: F,
747    ) -> Result<(), Box<dyn std::error::Error>>
748    where
749        F: FnMut(&str) -> Option<String>,
750    {
751        if let Some(address) = get_env("TYPEDB_ADDRESS") {
752            self.typedb.address = address;
753        }
754        if let Some(database) = get_env("TYPEDB_DATABASE") {
755            self.typedb.database = database;
756        }
757        if let Some(username) = get_env("TYPEDB_USERNAME") {
758            self.typedb.username = username;
759        }
760        if let Some(password) = get_env("TYPEDB_PASSWORD") {
761            self.typedb.password = password;
762        }
763        if let Some(raw) = get_env("TYPEDB_HTTP_PORT") {
764            self.typedb.http_port = raw.parse::<u16>().map_err(|_| {
765                format!("TYPEDB_HTTP_PORT must be a valid port number (0–65535), got {raw:?}")
766            })?;
767        }
768        if let Some(server_version) = get_env("TYPEDB_SERVER_VERSION") {
769            self.typedb.server_version = Some(server_version);
770        }
771        Ok(())
772    }
773}
774
775impl RuntimeServerConfig {
776    /// Load the standalone runtime projection, including secure and V2 TOML.
777    pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
778        Self::from_file_with_env(path, |name| std::env::var(name).ok())
779    }
780
781    /// Return the immutable V2 declared-schema bytes captured by [`Self::from_file`].
782    ///
783    /// A caller that mutates the public diagnostic path after loading must
784    /// reload the complete configuration instead of pairing it with stale
785    /// schema authority.
786    #[cfg(feature = "v2-query")]
787    pub fn v2_declared_schema_bytes(&self) -> Result<Option<&[u8]>, &'static str> {
788        let Some(material) = &self.typedb.v2_declared_schema_snapshot else {
789            return Ok(None);
790        };
791        if material.path != Path::new(&self.v2.declared_schema_file) {
792            return Err(
793                "v2.declared_schema_file changed after its bytes were captured; reload the configuration",
794            );
795        }
796        Ok(Some(material.bytes.as_ref()))
797    }
798
799    fn from_file_with_env<F>(path: &str, mut get_env: F) -> Result<Self, Box<dyn std::error::Error>>
800    where
801        F: FnMut(&str) -> Option<String>,
802    {
803        Self::from_file_with_env_after_probes(path, &mut get_env, || {}, || {})
804    }
805
806    #[cfg(test)]
807    fn from_file_with_env_after_probe<F, H>(
808        path: &str,
809        mut get_env: F,
810        after_probe: H,
811    ) -> Result<Self, Box<dyn std::error::Error>>
812    where
813        F: FnMut(&str) -> Option<String>,
814        H: FnOnce(),
815    {
816        Self::from_file_with_env_after_probes(path, &mut get_env, after_probe, || {})
817    }
818
819    fn from_file_with_env_after_probes<F, H, I>(
820        path: &str,
821        mut get_env: F,
822        after_probe: H,
823        after_authorized_read: I,
824    ) -> Result<Self, Box<dyn std::error::Error>>
825    where
826        F: FnMut(&str) -> Option<String>,
827        H: FnOnce(),
828        I: FnOnce(),
829    {
830        let (mut wire, relative_authority) = load_runtime_wire(path, after_probe)?;
831        // Tests use this seam to prove that every relative authority-bearing
832        // runtime file is opened through the already-retained directory even
833        // if its ambient name is replaced after the authoritative read.
834        after_authorized_read();
835        if let Some(address) = get_env("TYPEDB_ADDRESS") {
836            wire.typedb.address = address;
837        }
838        if let Some(database) = get_env("TYPEDB_DATABASE") {
839            wire.typedb.database = database;
840        }
841        if let Some(username) = get_env("TYPEDB_USERNAME") {
842            wire.typedb.username = username;
843        }
844        if let Some(password) = get_env("TYPEDB_PASSWORD") {
845            wire.typedb.password = password;
846        }
847        if let Some(raw) = get_env("TYPEDB_HTTP_PORT") {
848            wire.typedb.http_port = raw.parse::<u16>().map_err(|_| {
849                format!("TYPEDB_HTTP_PORT must be a valid port number (0–65535), got {raw:?}")
850            })?;
851        }
852        if let Some(server_version) = get_env("TYPEDB_SERVER_VERSION") {
853            wire.typedb.server_version = Some(server_version);
854        }
855
856        // Reject contradictions before resolving or opening any configured
857        // trust or identity path.
858        let tls_mode = outbound_tls_mode(wire.typedb.tls, wire.typedb.tls_root_ca.take())?;
859        let mut custom_root_ca_snapshot = None;
860        let tls_mode = match tls_mode {
861            OutboundTlsMode::CustomRootCa(path) => {
862                let resolved = resolve_configured_file(
863                    relative_authority.as_ref(),
864                    &path,
865                    "typedb.tls-root-ca",
866                )?;
867                custom_root_ca_snapshot =
868                    resolved.snapshot.map(|bytes| CapturedConfiguredMaterial {
869                        path: resolved.path.clone(),
870                        bytes,
871                    });
872                OutboundTlsMode::CustomRootCa(resolved.path)
873            }
874            other => other,
875        };
876        if let Some(tls) = &mut wire.server.tls {
877            let certificate = resolve_configured_file(
878                relative_authority.as_ref(),
879                &tls.cert_path,
880                "server.tls.cert-path",
881            )?;
882            let private_key = resolve_configured_file(
883                relative_authority.as_ref(),
884                &tls.key_path,
885                "server.tls.key-path",
886            )?;
887            tls.cert_path = certificate.path;
888            tls.key_path = private_key.path;
889            if certificate.snapshot.is_some() || private_key.snapshot.is_some() {
890                tls.prepared = Some(PreparedInboundTlsMaterial {
891                    certificate: certificate
892                        .snapshot
893                        .map(|bytes| CapturedConfiguredMaterial {
894                            path: tls.cert_path.clone(),
895                            bytes,
896                        }),
897                    private_key: private_key
898                        .snapshot
899                        .map(|bytes| CapturedConfiguredMaterial {
900                            path: tls.key_path.clone(),
901                            bytes,
902                        }),
903                });
904            }
905        }
906
907        #[cfg(feature = "v2-query")]
908        let v2_declared_schema_snapshot = if wire.v2.enabled
909            && !wire.v2.declared_schema_file.is_empty()
910        {
911            let configured_path = Path::new(&wire.v2.declared_schema_file);
912            Some(CapturedConfiguredMaterial {
913                path: configured_path.to_path_buf(),
914                bytes: capture_declared_schema_file(relative_authority.as_ref(), configured_path)?,
915            })
916        } else {
917            None
918        };
919
920        Ok(Self {
921            server: ServerSection {
922                host: wire.server.host,
923                port: wire.server.port,
924            },
925            typedb: SecureTypeDBSection {
926                connection: TypeDBSection {
927                    address: wire.typedb.address,
928                    database: wire.typedb.database,
929                    username: wire.typedb.username,
930                    password: wire.typedb.password,
931                    http_port: wire.typedb.http_port,
932                    server_version: wire.typedb.server_version,
933                },
934                tls_mode,
935                custom_root_ca_snapshot,
936                #[cfg(feature = "v2-query")]
937                v2_declared_schema_snapshot,
938            },
939            schema: wire.schema.into(),
940            interceptors: wire.interceptors.into(),
941            logging: wire.logging.into(),
942            inbound_tls: wire.server.tls,
943            v2: wire.v2.into(),
944        })
945    }
946}
947
948fn read_runtime_config_path(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
949    let mut options = std::fs::OpenOptions::new();
950    options.read(true);
951    #[cfg(unix)]
952    options.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32);
953    #[cfg(windows)]
954    {
955        const FILE_SHARE_READ: u32 = 0x0000_0001;
956        // Backup semantics let a directory path open so the regular-file
957        // classification comes from the opened handle's metadata, exactly
958        // as it does on Unix.
959        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
960        options.share_mode(FILE_SHARE_READ);
961        options.custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
962    }
963    let file = options
964        .open(path)
965        .map_err(|error| format!("cannot read server configuration: {error}"))?;
966    read_runtime_config_handle(file)
967}
968
969fn read_runtime_config_handle(file: std::fs::File) -> Result<String, Box<dyn std::error::Error>> {
970    read_runtime_config_handle_with_hooks(file, || {}, || {})
971}
972
973#[cfg(test)]
974fn read_runtime_config_handle_after_inspect<H>(
975    file: std::fs::File,
976    after_inspect: H,
977) -> Result<String, Box<dyn std::error::Error>>
978where
979    H: FnOnce(),
980{
981    read_runtime_config_handle_with_hooks(file, after_inspect, || {})
982}
983
984fn read_runtime_config_handle_with_hooks<H, I>(
985    mut file: std::fs::File,
986    after_inspect: H,
987    after_first_read: I,
988) -> Result<String, Box<dyn std::error::Error>>
989where
990    H: FnOnce(),
991    I: FnOnce(),
992{
993    let metadata = file
994        .metadata()
995        .map_err(|error| format!("cannot inspect server configuration: {error}"))?;
996    if !metadata.is_file() || metadata.len() > MAX_SERVER_CONFIG_BYTES {
997        return Err("server configuration must name a regular file no larger than 1 MiB".into());
998    }
999    after_inspect();
1000    let mut bytes = Vec::new();
1001    (&mut file)
1002        .take(MAX_SERVER_CONFIG_BYTES + 1)
1003        .read_to_end(&mut bytes)
1004        .map_err(|error| format!("cannot read server configuration: {error}"))?;
1005    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_SERVER_CONFIG_BYTES {
1006        return Err("server configuration must be no larger than 1 MiB".into());
1007    }
1008    after_first_read();
1009    file.seek(SeekFrom::Start(0))
1010        .map_err(|error| format!("cannot reread server configuration: {error}"))?;
1011    let mut verification_bytes = Vec::new();
1012    (&mut file)
1013        .take(MAX_SERVER_CONFIG_BYTES + 1)
1014        .read_to_end(&mut verification_bytes)
1015        .map_err(|error| format!("cannot reread server configuration: {error}"))?;
1016    let after = file
1017        .metadata()
1018        .map_err(|error| format!("cannot inspect server configuration: {error}"))?;
1019    let timestamps_match = match (metadata.modified(), after.modified()) {
1020        (Ok(before), Ok(after)) => before == after,
1021        (Err(_), Err(_)) => true,
1022        _ => false,
1023    };
1024    if metadata.len() != after.len()
1025        || metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
1026        || bytes != verification_bytes
1027        || !timestamps_match
1028    {
1029        return Err("server configuration changed while it was being read".into());
1030    }
1031    String::from_utf8(bytes).map_err(|_| "server configuration must be valid UTF-8".into())
1032}
1033
1034fn load_runtime_wire<H>(
1035    path: &str,
1036    after_probe: H,
1037) -> Result<(RuntimeServerConfigWire, Option<RelativeConfigAuthority>), Box<dyn std::error::Error>>
1038where
1039    H: FnOnce(),
1040{
1041    let content = read_runtime_config_path(Path::new(path))?;
1042    let wire = parse_runtime_wire(&content)?;
1043    // Preserve fail-fast contradiction handling: a relative path must not
1044    // cause any additional pathname lookup when the policy itself is invalid.
1045    outbound_tls_mode(wire.typedb.tls, wire.typedb.tls_root_ca.clone())?;
1046    after_probe();
1047
1048    if !wire_uses_relative_runtime_paths(&wire) {
1049        return Ok((wire, None));
1050    }
1051
1052    // The preliminary bytes only determine whether compatibility permits a
1053    // single read. Once relative security material is present, resolve the
1054    // configuration identity first and use bytes read through that resolved
1055    // path. This prevents a configuration symlink swap from pairing one
1056    // target's policy with another target's base directory.
1057    let resolved_config = Path::new(path).canonicalize()?;
1058    let authority = RelativeConfigAuthority::open(&resolved_config)?;
1059    let content = authority.read_config()?;
1060    let wire = parse_runtime_wire(&content)?;
1061    outbound_tls_mode(wire.typedb.tls, wire.typedb.tls_root_ca.clone())?;
1062    let relative_authority = if wire_uses_relative_runtime_paths(&wire) {
1063        Some(authority)
1064    } else {
1065        None
1066    };
1067    Ok((wire, relative_authority))
1068}
1069
1070struct RelativeConfigAuthority {
1071    directory: Dir,
1072    ancestors: Vec<Dir>,
1073    config_name: OsString,
1074    display_base: PathBuf,
1075}
1076
1077impl RelativeConfigAuthority {
1078    fn open(resolved_config: &Path) -> Result<Self, Box<dyn std::error::Error>> {
1079        let display_base = resolved_config
1080            .parent()
1081            .ok_or("server configuration path has no parent directory")?
1082            .to_path_buf();
1083        let config_name = resolved_config
1084            .file_name()
1085            .map(OsString::from)
1086            .ok_or("server configuration path has no file name")?;
1087        // Open every absolute component from a retained root handle. Keeping
1088        // the complete lineage makes `../` relative paths stable too: a later
1089        // rename cannot cause `open_parent_dir` to discover a different
1090        // ambient parent.
1091        let mut components = display_base.components();
1092        let mut anchor = PathBuf::new();
1093        let mut saw_prefix = false;
1094        let mut saw_root = false;
1095        loop {
1096            match components.clone().next() {
1097                Some(Component::Prefix(prefix)) if !saw_prefix && !saw_root => {
1098                    anchor.push(prefix.as_os_str());
1099                    saw_prefix = true;
1100                    let _ = components.next();
1101                }
1102                Some(Component::RootDir) if !saw_root => {
1103                    anchor.push(Component::RootDir.as_os_str());
1104                    saw_root = true;
1105                    let _ = components.next();
1106                }
1107                _ => break,
1108            }
1109        }
1110        if !saw_root {
1111            return Err("resolved server configuration directory is not absolute".into());
1112        }
1113        let mut directory = Dir::open_ambient_dir(&anchor, ambient_authority())?;
1114        let mut ancestors = Vec::new();
1115        for component in components {
1116            match component {
1117                Component::CurDir => {}
1118                Component::Normal(name) => {
1119                    let child = directory.open_dir_nofollow(name)?;
1120                    ancestors.push(directory);
1121                    directory = child;
1122                }
1123                Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
1124                    return Err(
1125                        "resolved server configuration directory has an invalid component".into(),
1126                    );
1127                }
1128            }
1129        }
1130        Ok(Self {
1131            directory,
1132            ancestors,
1133            config_name,
1134            display_base,
1135        })
1136    }
1137
1138    fn read_config(&self) -> Result<String, Box<dyn std::error::Error>> {
1139        let mut options = OpenOptions::new();
1140        options.read(true).follow(FollowSymlinks::No);
1141        // A directory must open (Windows needs backup semantics for that)
1142        // so the regular-file classification comes from the opened handle's
1143        // metadata, exactly as it does on Unix.
1144        options.maybe_dir(true);
1145        #[cfg(unix)]
1146        options.nonblock(true);
1147        #[cfg(windows)]
1148        {
1149            const FILE_SHARE_READ: u32 = 0x0000_0001;
1150            options.share_mode(FILE_SHARE_READ);
1151        }
1152        let file = self
1153            .directory
1154            .open_with(Path::new(&self.config_name), &options)
1155            .map(cap_std::fs::File::into_std)?;
1156        read_runtime_config_handle(file)
1157    }
1158
1159    fn open_relative_file_nofollow(
1160        &self,
1161        path: &Path,
1162        field: &str,
1163    ) -> Result<std::fs::File, Box<dyn std::error::Error>> {
1164        if path.as_os_str().is_empty() || path.is_absolute() {
1165            return Err(format!("{field} must be a non-empty relative path").into());
1166        }
1167        let file_name = path
1168            .file_name()
1169            .map(OsString::from)
1170            .ok_or_else(|| format!("{field} must name a file"))?;
1171        let parent = path.parent().unwrap_or_else(|| Path::new(""));
1172        let mut directory = self
1173            .directory
1174            .try_clone()
1175            .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1176        let mut ancestors = self
1177            .ancestors
1178            .iter()
1179            .map(Dir::try_clone)
1180            .collect::<Result<Vec<_>, _>>()
1181            .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1182        for component in parent.components() {
1183            match component {
1184                Component::CurDir => {}
1185                Component::ParentDir => {
1186                    directory = ancestors.pop().ok_or_else(|| {
1187                        format!("cannot resolve {field}: path escapes the filesystem root")
1188                    })?;
1189                }
1190                Component::Normal(name) => {
1191                    let child = directory
1192                        .open_dir_nofollow(name)
1193                        .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1194                    ancestors.push(directory);
1195                    directory = child;
1196                }
1197                Component::Prefix(_) | Component::RootDir => {
1198                    return Err(format!("{field} contains an invalid path component").into());
1199                }
1200            }
1201        }
1202
1203        let mut options = OpenOptions::new();
1204        options.read(true).follow(FollowSymlinks::No);
1205        // A directory must open (Windows needs backup semantics for that)
1206        // so the regular-file classification comes from the opened handle's
1207        // metadata, exactly as it does on Unix.
1208        options.maybe_dir(true);
1209        #[cfg(unix)]
1210        options.nonblock(true);
1211        #[cfg(windows)]
1212        {
1213            // Permit only concurrent readers while the bounded snapshot is
1214            // captured; replacement and writes remain denied on Windows.
1215            const FILE_SHARE_READ: u32 = 0x0000_0001;
1216            options.share_mode(FILE_SHARE_READ);
1217        }
1218        directory
1219            .open_with(Path::new(&file_name), &options)
1220            .map(cap_std::fs::File::into_std)
1221            .map_err(|error| format!("cannot resolve {field}: {error}").into())
1222    }
1223
1224    fn capture_relative_file(
1225        &self,
1226        path: &Path,
1227        field: &str,
1228    ) -> Result<ResolvedConfiguredFile, Box<dyn std::error::Error>> {
1229        let mut file = self.open_relative_file_nofollow(path, field)?;
1230        let metadata = file
1231            .metadata()
1232            .map_err(|error| format!("cannot inspect {field}: {error}"))?;
1233        if !metadata.is_file() {
1234            return Err(format!("{field} must name a regular file").into());
1235        }
1236        if metadata.len() == 0 || metadata.len() > MAX_TLS_MATERIAL_BYTES {
1237            return Err(format!("{field} must be a non-empty file no larger than 1 MiB").into());
1238        }
1239        let mut bytes = Vec::new();
1240        (&mut file)
1241            .take(MAX_TLS_MATERIAL_BYTES + 1)
1242            .read_to_end(&mut bytes)
1243            .map_err(|error| format!("cannot read {field}: {error}"))?;
1244        if bytes.is_empty()
1245            || u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_TLS_MATERIAL_BYTES
1246        {
1247            return Err(format!("{field} must be a non-empty file no larger than 1 MiB").into());
1248        }
1249        file.seek(SeekFrom::Start(0))
1250            .map_err(|error| format!("cannot reread {field}: {error}"))?;
1251        let mut verification_bytes = Vec::new();
1252        (&mut file)
1253            .take(MAX_TLS_MATERIAL_BYTES + 1)
1254            .read_to_end(&mut verification_bytes)
1255            .map_err(|error| format!("cannot reread {field}: {error}"))?;
1256        let after = file
1257            .metadata()
1258            .map_err(|error| format!("cannot inspect {field}: {error}"))?;
1259        let timestamps_match = match (metadata.modified(), after.modified()) {
1260            (Ok(before), Ok(after)) => before == after,
1261            (Err(_), Err(_)) => true,
1262            _ => false,
1263        };
1264        if metadata.len() != after.len()
1265            || metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
1266            || bytes != verification_bytes
1267            || !timestamps_match
1268        {
1269            return Err(format!("{field} changed while it was being read").into());
1270        }
1271        Ok(ResolvedConfiguredFile {
1272            // This path remains a diagnostic projection only. Consumers use
1273            // `snapshot` for relative files, so an ambient parent replacement
1274            // cannot redirect a later TLS read through this name.
1275            path: self.display_base.join(path),
1276            snapshot: Some(bytes.into()),
1277        })
1278    }
1279
1280    #[cfg(feature = "v2-query")]
1281    fn capture_relative_declared_schema(
1282        &self,
1283        path: &Path,
1284    ) -> Result<Arc<[u8]>, Box<dyn std::error::Error>> {
1285        let file = self.open_relative_file_nofollow(path, "v2.declared_schema_file")?;
1286        capture_declared_schema_handle(file)
1287    }
1288}
1289
1290#[cfg(feature = "v2-query")]
1291fn capture_declared_schema_file(
1292    relative_authority: Option<&RelativeConfigAuthority>,
1293    path: &Path,
1294) -> Result<Arc<[u8]>, Box<dyn std::error::Error>> {
1295    if path.is_absolute() {
1296        return capture_absolute_declared_schema_file(path);
1297    }
1298    relative_authority
1299        .ok_or(
1300            "cannot resolve relative v2.declared_schema_file without the configuration directory",
1301        )?
1302        .capture_relative_declared_schema(path)
1303}
1304
1305#[cfg(feature = "v2-query")]
1306fn capture_absolute_declared_schema_file(
1307    path: &Path,
1308) -> Result<Arc<[u8]>, Box<dyn std::error::Error>> {
1309    let field = "v2.declared_schema_file";
1310    let anchor = path
1311        .ancestors()
1312        .last()
1313        .ok_or_else(|| format!("{field} has no filesystem anchor"))?;
1314    let relative = path
1315        .strip_prefix(anchor)
1316        .map_err(|_| format!("{field} is not beneath its filesystem anchor"))?;
1317    let components = relative
1318        .components()
1319        .map(|component| match component {
1320            Component::Normal(name) => Ok(name),
1321            _ => Err(format!("{field} contains an invalid path component")),
1322        })
1323        .collect::<Result<Vec<_>, _>>()?;
1324    let (name, parents) = components
1325        .split_last()
1326        .ok_or_else(|| format!("{field} has no file name"))?;
1327    let mut directory = Dir::open_ambient_dir(anchor, ambient_authority())
1328        .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1329    for parent in parents {
1330        directory = directory
1331            .open_dir_nofollow(parent)
1332            .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1333    }
1334    let mut options = OpenOptions::new();
1335    options.read(true).follow(FollowSymlinks::No);
1336    // A directory must open (Windows needs backup semantics for that) so
1337    // the regular-file classification comes from the opened handle's
1338    // metadata, exactly as it does on Unix.
1339    options.maybe_dir(true);
1340    #[cfg(unix)]
1341    options.nonblock(true);
1342    #[cfg(windows)]
1343    {
1344        const FILE_SHARE_READ: u32 = 0x0000_0001;
1345        options.share_mode(FILE_SHARE_READ);
1346    }
1347    let file = directory
1348        .open_with(name, &options)
1349        .map(cap_std::fs::File::into_std)
1350        .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1351    capture_declared_schema_handle(file)
1352}
1353
1354#[cfg(feature = "v2-query")]
1355fn capture_declared_schema_handle(
1356    mut file: std::fs::File,
1357) -> Result<Arc<[u8]>, Box<dyn std::error::Error>> {
1358    let field = "v2.declared_schema_file";
1359    let ceiling = u64::try_from(MAX_DECLARED_SCHEMA_BYTES)
1360        .map_err(|_| "canonical declared-schema byte ceiling is not representable")?;
1361    let metadata = file
1362        .metadata()
1363        .map_err(|error| format!("cannot inspect {field}: {error}"))?;
1364    if !metadata.is_file() {
1365        return Err(format!("{field} must name a regular file").into());
1366    }
1367    if metadata.len() == 0 || metadata.len() > ceiling {
1368        return Err(format!("{field} must be a non-empty file no larger than 16 MiB").into());
1369    }
1370
1371    let mut bytes = Vec::new();
1372    (&mut file)
1373        .take(ceiling + 1)
1374        .read_to_end(&mut bytes)
1375        .map_err(|error| format!("cannot read {field}: {error}"))?;
1376    if bytes.is_empty() || bytes.len() > MAX_DECLARED_SCHEMA_BYTES {
1377        return Err(format!("{field} must be a non-empty file no larger than 16 MiB").into());
1378    }
1379    file.seek(SeekFrom::Start(0))
1380        .map_err(|error| format!("cannot reread {field}: {error}"))?;
1381    let mut verification_bytes = Vec::new();
1382    (&mut file)
1383        .take(ceiling + 1)
1384        .read_to_end(&mut verification_bytes)
1385        .map_err(|error| format!("cannot reread {field}: {error}"))?;
1386    let after = file
1387        .metadata()
1388        .map_err(|error| format!("cannot inspect {field}: {error}"))?;
1389    let timestamps_match = match (metadata.modified(), after.modified()) {
1390        (Ok(before), Ok(after)) => before == after,
1391        (Err(_), Err(_)) => true,
1392        _ => false,
1393    };
1394    if metadata.len() != after.len()
1395        || metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
1396        || bytes != verification_bytes
1397        || !timestamps_match
1398    {
1399        return Err(format!("{field} changed while it was being read").into());
1400    }
1401    Ok(bytes.into())
1402}
1403
1404fn parse_runtime_wire(
1405    content: &str,
1406) -> Result<RuntimeServerConfigWire, Box<dyn std::error::Error>> {
1407    reject_ambiguous_security_keys(content)?;
1408    toml::from_str(content).map_err(|error| {
1409        RuntimeConfigParseError::from_toml(RuntimeConfigParseErrorKind::ValueShape, content, error)
1410            .into()
1411    })
1412}
1413
1414fn wire_uses_relative_runtime_paths(wire: &RuntimeServerConfigWire) -> bool {
1415    let uses_relative_tls_path = wire
1416        .typedb
1417        .tls_root_ca
1418        .as_deref()
1419        .is_some_and(|path| !path.is_absolute())
1420        || wire
1421            .server
1422            .tls
1423            .as_ref()
1424            .is_some_and(|tls| !tls.cert_path.is_absolute() || !tls.key_path.is_absolute());
1425    #[cfg(feature = "v2-query")]
1426    {
1427        uses_relative_tls_path
1428            || (wire.v2.enabled
1429                && !wire.v2.declared_schema_file.is_empty()
1430                && !Path::new(&wire.v2.declared_schema_file).is_absolute())
1431    }
1432    #[cfg(not(feature = "v2-query"))]
1433    {
1434        uses_relative_tls_path
1435    }
1436}
1437
1438/// Preserve the released parser's tolerance for extension keys without
1439/// allowing a misspelled TLS option to be silently ignored as plaintext.
1440fn reject_ambiguous_security_keys(content: &str) -> Result<(), Box<dyn std::error::Error>> {
1441    let document: toml::Value = toml::from_str(content).map_err(|error| {
1442        RuntimeConfigParseError::from_toml(RuntimeConfigParseErrorKind::Syntax, content, error)
1443    })?;
1444    let Some(root) = document.as_table() else {
1445        return Ok(());
1446    };
1447    for key in root.keys() {
1448        let path = [key.clone()];
1449        if security_shaped_key(key) && !documented_security_path(&path) {
1450            return Err(RuntimeConfigParseError::unknown_security_key().into());
1451        }
1452    }
1453    for namespace in ["server", "typedb"] {
1454        let Some(table) = root.get(namespace).and_then(toml::Value::as_table) else {
1455            continue;
1456        };
1457        for key in table.keys() {
1458            let path = [namespace.to_owned(), key.clone()];
1459            if security_shaped_key(key) && !documented_security_path(&path) {
1460                return Err(RuntimeConfigParseError::unknown_security_key().into());
1461            }
1462        }
1463    }
1464    Ok(())
1465}
1466
1467fn documented_security_path(path: &[String]) -> bool {
1468    matches!(
1469        path,
1470        [server, tls]
1471            if server == "server" && tls == "tls"
1472    ) || matches!(
1473        path,
1474        [server, tls, field]
1475            if server == "server"
1476                && tls == "tls"
1477                && matches!(field.as_str(), "cert-path" | "key-path")
1478    ) || matches!(
1479        path,
1480        [typedb, field]
1481            if typedb == "typedb" && matches!(field.as_str(), "tls" | "tls-root-ca")
1482    )
1483}
1484
1485fn security_shaped_key(key: &str) -> bool {
1486    let normalized = key
1487        .chars()
1488        .filter(|character| character.is_ascii_alphanumeric())
1489        .flat_map(char::to_lowercase)
1490        .collect::<String>();
1491    normalized.starts_with("tls")
1492        || normalized.ends_with("tls")
1493        || normalized.starts_with("ssl")
1494        || normalized.ends_with("ssl")
1495        || normalized.starts_with("https")
1496        || normalized.ends_with("https")
1497        || matches!(
1498            normalized.as_str(),
1499            "cafile"
1500                | "capath"
1501                | "rootca"
1502                | "rootcapath"
1503                | "truststore"
1504                | "truststorepath"
1505                | "certfile"
1506                | "certificatepath"
1507                | "certpath"
1508                | "clientcert"
1509                | "clientcertpath"
1510                | "keyfile"
1511                | "keypath"
1512                | "privatekey"
1513                | "privatekeypath"
1514        )
1515}
1516
1517impl OutboundTlsMode {
1518    /// Return whether this policy requires TLS on every outbound transport.
1519    #[must_use]
1520    pub const fn is_enabled(&self) -> bool {
1521        !matches!(self, Self::Disabled)
1522    }
1523}
1524
1525fn outbound_tls_mode(
1526    tls: Option<bool>,
1527    root: Option<PathBuf>,
1528) -> Result<OutboundTlsMode, Box<dyn std::error::Error>> {
1529    match (tls, root) {
1530        (None | Some(false), None) => Ok(OutboundTlsMode::Disabled),
1531        (Some(true), None) => Ok(OutboundTlsMode::NativeRoots),
1532        (Some(true), Some(path)) => Ok(OutboundTlsMode::CustomRootCa(path)),
1533        (Some(false), Some(_)) => Err("typedb.tls-root-ca contradicts typedb.tls = false".into()),
1534        (None, Some(_)) => Err("typedb.tls-root-ca requires explicit typedb.tls = true".into()),
1535    }
1536}
1537
1538struct ResolvedConfiguredFile {
1539    path: PathBuf,
1540    snapshot: Option<Arc<[u8]>>,
1541}
1542
1543fn capture_tls_material_handle(
1544    mut file: std::fs::File,
1545    field: &str,
1546) -> Result<Arc<[u8]>, Box<dyn std::error::Error>> {
1547    let metadata = file
1548        .metadata()
1549        .map_err(|error| format!("cannot inspect {field}: {error}"))?;
1550    if !metadata.is_file() {
1551        return Err(format!("{field} must name a regular file").into());
1552    }
1553    if metadata.len() == 0 || metadata.len() > MAX_TLS_MATERIAL_BYTES {
1554        return Err(format!("{field} must be a non-empty file no larger than 1 MiB").into());
1555    }
1556
1557    let mut bytes = Vec::new();
1558    (&mut file)
1559        .take(MAX_TLS_MATERIAL_BYTES + 1)
1560        .read_to_end(&mut bytes)
1561        .map_err(|error| format!("cannot read {field}: {error}"))?;
1562    if bytes.is_empty() || u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_TLS_MATERIAL_BYTES {
1563        return Err(format!("{field} must be a non-empty file no larger than 1 MiB").into());
1564    }
1565    file.seek(SeekFrom::Start(0))
1566        .map_err(|error| format!("cannot reread {field}: {error}"))?;
1567    let mut verification_bytes = Vec::new();
1568    (&mut file)
1569        .take(MAX_TLS_MATERIAL_BYTES + 1)
1570        .read_to_end(&mut verification_bytes)
1571        .map_err(|error| format!("cannot reread {field}: {error}"))?;
1572    let after = file
1573        .metadata()
1574        .map_err(|error| format!("cannot inspect {field}: {error}"))?;
1575    let timestamps_match = match (metadata.modified(), after.modified()) {
1576        (Ok(before), Ok(after)) => before == after,
1577        (Err(_), Err(_)) => true,
1578        _ => false,
1579    };
1580    if metadata.len() != after.len()
1581        || metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
1582        || bytes != verification_bytes
1583        || !timestamps_match
1584    {
1585        return Err(format!("{field} changed while it was being read").into());
1586    }
1587    Ok(bytes.into())
1588}
1589
1590fn capture_absolute_configured_file(
1591    path: &Path,
1592    field: &str,
1593) -> Result<ResolvedConfiguredFile, Box<dyn std::error::Error>> {
1594    if path.as_os_str().is_empty() || !path.is_absolute() {
1595        return Err(format!("{field} must be a non-empty absolute path").into());
1596    }
1597    let mut options = std::fs::OpenOptions::new();
1598    options.read(true);
1599    #[cfg(unix)]
1600    options.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32);
1601    #[cfg(windows)]
1602    {
1603        const FILE_SHARE_READ: u32 = 0x0000_0001;
1604        // Backup semantics let a directory path open so the regular-file
1605        // classification comes from the opened handle's metadata, exactly
1606        // as it does on Unix.
1607        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1608        options.share_mode(FILE_SHARE_READ);
1609        options.custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
1610    }
1611    let file = options
1612        .open(path)
1613        .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1614    let snapshot = capture_tls_material_handle(file, field)?;
1615    let canonical = path
1616        .canonicalize()
1617        .map_err(|error| format!("cannot resolve {field}: {error}"))?;
1618    Ok(ResolvedConfiguredFile {
1619        path: canonical,
1620        snapshot: Some(snapshot),
1621    })
1622}
1623
1624fn resolve_configured_file(
1625    relative_authority: Option<&RelativeConfigAuthority>,
1626    path: &Path,
1627    field: &str,
1628) -> Result<ResolvedConfiguredFile, Box<dyn std::error::Error>> {
1629    if path.is_absolute() {
1630        return capture_absolute_configured_file(path, field);
1631    }
1632    let authority = relative_authority.ok_or_else(|| {
1633        format!("cannot resolve relative {field} without the configuration directory")
1634    })?;
1635    authority.capture_relative_file(path, field)
1636}
1637
1638#[cfg(test)]
1639#[cfg_attr(coverage_nightly, coverage(off))]
1640mod tests {
1641    use super::*;
1642
1643    const FULL_CONFIG: &str = r#"
1644[server]
1645host = "127.0.0.1"
1646port = 9090
1647
1648[typedb]
1649address = "localhost:1729"
1650database = "mydb"
1651username = "root"
1652password = "secret"
1653server_version = "3.11.5"
1654
1655[schema]
1656source_file = "schema.tql"
1657
1658[interceptors]
1659enabled = ["audit-log"]
1660
1661[interceptors.audit-log]
1662output = "file"
1663file_path = "/tmp/audit.log"
1664
1665[logging]
1666level = "debug"
1667format = "text"
1668"#;
1669
1670    const MINIMAL_CONFIG: &str = r#"
1671[server]
1672
1673[typedb]
1674address = "localhost:1729"
1675database = "mydb"
1676"#;
1677
1678    // --- from_file tests ---
1679
1680    #[test]
1681    fn from_file_valid_full_config() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let path = dir.path().join("server.toml");
1684        std::fs::write(&path, FULL_CONFIG).unwrap();
1685
1686        let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
1687        assert_eq!(config.server.host, "127.0.0.1");
1688        assert_eq!(config.server.port, 9090);
1689        assert_eq!(config.typedb.address, "localhost:1729");
1690        assert_eq!(config.typedb.database, "mydb");
1691        assert_eq!(config.typedb.username, "root");
1692        assert_eq!(config.typedb.password, "secret");
1693        assert_eq!(config.typedb.server_version.as_deref(), Some("3.11.5"));
1694        assert_eq!(config.schema.source_file, "schema.tql");
1695        assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
1696        let audit = config.interceptors.audit_log.unwrap();
1697        assert_eq!(audit.output, "file");
1698        assert_eq!(audit.file_path, "/tmp/audit.log");
1699        assert_eq!(config.logging.level, "debug");
1700        assert_eq!(config.logging.format, "text");
1701    }
1702
1703    #[test]
1704    fn v2_section_defaults_to_disabled() {
1705        let dir = tempfile::tempdir().unwrap();
1706        let path = dir.path().join("server.toml");
1707        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
1708
1709        let config =
1710            RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
1711        assert!(!config.v2.enabled);
1712        assert!(config.v2.declared_schema_file.is_empty());
1713        assert_eq!(config.v2.authority_mode, V2AuthorityMode::Managed);
1714        assert_eq!(config.typedb.tls_mode, OutboundTlsMode::Disabled);
1715        assert!(config.inbound_tls.is_none());
1716    }
1717
1718    #[test]
1719    fn v2_section_parses_when_configured() {
1720        let config_text = format!(
1721            "{MINIMAL_CONFIG}\n[v2]\nenabled = true\n\
1722             declared_schema_file = \"declared-schema.json\"\n\
1723             scope = \"prod\"\nprofile = \"typedb-3.12.1/v1\"\n\
1724             authority_mode = \"query_only\"\n"
1725        );
1726        let dir = tempfile::tempdir().unwrap();
1727        let path = dir.path().join("server.toml");
1728        std::fs::write(&path, config_text).unwrap();
1729        #[cfg(feature = "v2-query")]
1730        std::fs::write(dir.path().join("declared-schema.json"), "captured schema").unwrap();
1731
1732        let config =
1733            RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
1734        assert!(config.v2.enabled);
1735        assert_eq!(config.v2.declared_schema_file, "declared-schema.json");
1736        assert_eq!(config.v2.scope, "prod");
1737        assert_eq!(config.v2.profile, "typedb-3.12.1/v1");
1738        assert_eq!(config.v2.authority_mode, V2AuthorityMode::QueryOnly);
1739    }
1740
1741    #[cfg(feature = "v2-query")]
1742    #[test]
1743    fn relative_v2_declared_schema_is_config_relative_and_snapshot_stable() {
1744        let dir = tempfile::tempdir().unwrap();
1745        let schemas = dir.path().join("schemas");
1746        std::fs::create_dir(&schemas).unwrap();
1747        let declared_path = schemas.join("declared-schema.json");
1748        let original = b"captured declared schema";
1749        std::fs::write(&declared_path, original).unwrap();
1750        let config_path = dir.path().join("server.toml");
1751        std::fs::write(
1752            &config_path,
1753            format!(
1754                "{MINIMAL_CONFIG}\n[schema]\nsource_file = \"released-schema.tql\"\n\
1755                 [v2]\nenabled = true\ndeclared_schema_file = \"schemas/declared-schema.json\"\n\
1756                 scope = \"prod\"\nprofile = \"typedb-3.12.1/v1\"\n"
1757            ),
1758        )
1759        .unwrap();
1760
1761        let mut config =
1762            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
1763                .unwrap();
1764        assert_eq!(
1765            config.v2.declared_schema_file,
1766            "schemas/declared-schema.json"
1767        );
1768        assert_eq!(config.schema.source_file, "released-schema.tql");
1769        assert_eq!(
1770            config.v2_declared_schema_bytes().unwrap(),
1771            Some(original.as_slice())
1772        );
1773
1774        std::fs::write(&declared_path, "ambient replacement").unwrap();
1775        assert_eq!(
1776            config.v2_declared_schema_bytes().unwrap(),
1777            Some(original.as_slice()),
1778            "post-load replacement must not change V2 schema authority"
1779        );
1780
1781        config.v2.declared_schema_file = "other.json".to_owned();
1782        let error = config
1783            .v2_declared_schema_bytes()
1784            .expect_err("a mutated public path must not detach the captured bytes");
1785        assert!(error.contains("changed after"), "{error}");
1786    }
1787
1788    #[cfg(all(feature = "v2-query", unix))]
1789    #[test]
1790    fn relative_v2_declared_schema_uses_retained_base_after_ambient_parent_swap() {
1791        let dir = tempfile::tempdir().unwrap();
1792        let active = dir.path().join("active");
1793        let retained = dir.path().join("retained");
1794        std::fs::create_dir_all(active.join("schemas")).unwrap();
1795        let original = b"original declared schema";
1796        std::fs::write(active.join("schemas/declared-schema.json"), original).unwrap();
1797        let config_text = format!(
1798            "{MINIMAL_CONFIG}\n[v2]\nenabled = true\n\
1799             declared_schema_file = \"schemas/declared-schema.json\"\n\
1800             scope = \"prod\"\nprofile = \"typedb-3.12.1/v1\"\n"
1801        );
1802        let config_path = active.join("server.toml");
1803        std::fs::write(&config_path, &config_text).unwrap();
1804        let active_for_swap = active.clone();
1805        let retained_for_swap = retained.clone();
1806
1807        let config = RuntimeServerConfig::from_file_with_env_after_probes(
1808            config_path.to_str().unwrap(),
1809            |_| None,
1810            || {},
1811            move || {
1812                std::fs::rename(&active_for_swap, &retained_for_swap).unwrap();
1813                std::fs::create_dir_all(active_for_swap.join("schemas")).unwrap();
1814                std::fs::write(
1815                    active_for_swap.join("schemas/declared-schema.json"),
1816                    "replacement declared schema",
1817                )
1818                .unwrap();
1819                std::fs::write(active_for_swap.join("server.toml"), config_text).unwrap();
1820            },
1821        )
1822        .unwrap();
1823
1824        assert_eq!(
1825            config.v2_declared_schema_bytes().unwrap(),
1826            Some(original.as_slice()),
1827            "ambient parent replacement must not redirect the retained config authority"
1828        );
1829        assert_eq!(
1830            std::fs::read(active.join("schemas/declared-schema.json")).unwrap(),
1831            b"replacement declared schema"
1832        );
1833    }
1834
1835    #[cfg(all(feature = "v2-query", unix))]
1836    #[test]
1837    fn absolute_v2_declared_schema_symlink_is_rejected() {
1838        use std::os::unix::fs::symlink;
1839
1840        let dir = tempfile::tempdir().unwrap();
1841        let target = dir.path().join("target.json");
1842        let declared_path = dir.path().join("declared-schema.json");
1843        std::fs::write(&target, "target schema").unwrap();
1844        symlink(&target, &declared_path).unwrap();
1845        let config_path = dir.path().join("server.toml");
1846        std::fs::write(
1847            &config_path,
1848            format!(
1849                "{MINIMAL_CONFIG}\n[v2]\nenabled = true\ndeclared_schema_file = {:?}\n\
1850                 scope = \"prod\"\nprofile = \"typedb-3.12.1/v1\"\n",
1851                declared_path.to_str().unwrap()
1852            ),
1853        )
1854        .unwrap();
1855
1856        let error =
1857            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
1858                .expect_err("the final V2 schema component must not follow symlinks");
1859        assert!(
1860            error.to_string().contains("v2.declared_schema_file"),
1861            "{error}"
1862        );
1863    }
1864
1865    #[cfg(feature = "v2-query")]
1866    #[test]
1867    fn non_regular_v2_declared_schema_is_rejected() {
1868        let dir = tempfile::tempdir().unwrap();
1869        std::fs::create_dir(dir.path().join("declared-schema.json")).unwrap();
1870        let config_path = dir.path().join("server.toml");
1871        std::fs::write(
1872            &config_path,
1873            format!(
1874                "{MINIMAL_CONFIG}\n[v2]\nenabled = true\n\
1875                 declared_schema_file = \"declared-schema.json\"\n\
1876                 scope = \"prod\"\nprofile = \"typedb-3.12.1/v1\"\n"
1877            ),
1878        )
1879        .unwrap();
1880
1881        let error =
1882            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
1883                .expect_err("a V2 schema directory must fail during config loading");
1884        assert!(error.to_string().contains("regular file"), "{error}");
1885    }
1886
1887    #[cfg(feature = "v2-query")]
1888    #[test]
1889    fn oversized_v2_declared_schema_is_rejected_at_the_canonical_ceiling() {
1890        let dir = tempfile::tempdir().unwrap();
1891        let declared_path = dir.path().join("declared-schema.json");
1892        let file = std::fs::File::create(&declared_path).unwrap();
1893        file.set_len(u64::try_from(MAX_DECLARED_SCHEMA_BYTES).unwrap() + 1)
1894            .unwrap();
1895        // Close the writable fixture handle before loading: the capture
1896        // opens the file denying concurrent writers, so a live writer
1897        // handle on Windows is a sharing violation, not a size failure.
1898        drop(file);
1899        let config_path = dir.path().join("server.toml");
1900        std::fs::write(
1901            &config_path,
1902            format!(
1903                "{MINIMAL_CONFIG}\n[v2]\nenabled = true\n\
1904                 declared_schema_file = \"declared-schema.json\"\n\
1905                 scope = \"prod\"\nprofile = \"typedb-3.12.1/v1\"\n"
1906            ),
1907        )
1908        .unwrap();
1909
1910        let error =
1911            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
1912                .expect_err("an oversized V2 schema must fail during config loading");
1913        assert!(
1914            error.to_string().contains("no larger than 16 MiB"),
1915            "{error}"
1916        );
1917    }
1918
1919    #[test]
1920    fn runtime_wire_preserves_released_unknown_field_tolerance() {
1921        let cases = [
1922            (
1923                "root",
1924                r#"unexpected = true
1925[server]
1926[typedb]
1927address = "localhost:1729"
1928database = "db"
1929"#,
1930            ),
1931            (
1932                "server",
1933                r#"[server]
1934vendor_extension = true
1935[typedb]
1936address = "localhost:1729"
1937database = "db"
1938"#,
1939            ),
1940            (
1941                "typedb",
1942                r#"[server]
1943[typedb]
1944address = "localhost:1729"
1945database = "db"
1946vendor_extension = true
1947"#,
1948            ),
1949            (
1950                "schema",
1951                r#"[server]
1952[typedb]
1953address = "localhost:1729"
1954database = "db"
1955[schema]
1956source-file = "schema.tql"
1957"#,
1958            ),
1959            (
1960                "interceptors",
1961                r#"[server]
1962[typedb]
1963address = "localhost:1729"
1964database = "db"
1965[interceptors]
1966enable = ["audit-log"]
1967"#,
1968            ),
1969            (
1970                "audit-log",
1971                r#"[server]
1972[typedb]
1973address = "localhost:1729"
1974database = "db"
1975[interceptors]
1976enabled = ["audit-log"]
1977[interceptors.audit-log]
1978file-path = "audit.jsonl"
1979"#,
1980            ),
1981            (
1982                "logging",
1983                r#"[server]
1984[typedb]
1985address = "localhost:1729"
1986database = "db"
1987[logging]
1988log-level = "debug"
1989"#,
1990            ),
1991        ];
1992
1993        for (level, source) in cases {
1994            toml::from_str::<RuntimeServerConfigWire>(source).unwrap_or_else(|error| {
1995                panic!("released {level} extension key regressed: {error}")
1996            });
1997        }
1998    }
1999
2000    #[test]
2001    fn new_security_sections_remain_closed_to_unknown_keys() {
2002        let cases = [
2003            r#"[server]
2004[typedb]
2005address = "localhost:1729"
2006database = "db"
2007[v2]
2008enable = true
2009"#,
2010            r#"[server]
2011[server.tls]
2012cert-path = "server.pem"
2013key-path = "server.key"
2014certificate-path = "ignored.pem"
2015[typedb]
2016address = "localhost:1729"
2017database = "db"
2018"#,
2019        ];
2020        for source in cases {
2021            let error = toml::from_str::<RuntimeServerConfigWire>(source)
2022                .expect_err("new security-sensitive sections must fail closed");
2023            assert!(error.to_string().contains("unknown field"), "{error}");
2024        }
2025    }
2026
2027    #[test]
2028    fn ambiguous_tls_aliases_cannot_silently_select_plaintext() {
2029        let cases = [
2030            (
2031                "server tls alias",
2032                r#"[server]
2033tls-enabled = true
2034[typedb]
2035address = "localhost:1729"
2036database = "db"
2037"#,
2038            ),
2039            (
2040                "typedb tls alias",
2041                r#"[server]
2042[typedb]
2043address = "localhost:1729"
2044database = "db"
2045tls_enabled = true
2046"#,
2047            ),
2048            (
2049                "root CA alias",
2050                r#"[server]
2051[typedb]
2052address = "localhost:1729"
2053database = "db"
2054ca-file = "root.pem"
2055"#,
2056            ),
2057            (
2058                "hyphenated top-level table",
2059                r#"[server-tls]
2060enabled = true
2061[server]
2062[typedb]
2063address = "localhost:1729"
2064database = "db"
2065"#,
2066            ),
2067            (
2068                "underscored top-level table",
2069                r#"[server_tls]
2070enabled = true
2071[server]
2072[typedb]
2073address = "localhost:1729"
2074database = "db"
2075"#,
2076            ),
2077            (
2078                "top-level TLS table",
2079                r#"[tls]
2080enabled = true
2081[server]
2082[typedb]
2083address = "localhost:1729"
2084database = "db"
2085"#,
2086            ),
2087            (
2088                "nested SSL table",
2089                r#"[server]
2090[typedb]
2091address = "localhost:1729"
2092database = "db"
2093[typedb.ssl]
2094enabled = true
2095"#,
2096            ),
2097            (
2098                "top-level SSL flag",
2099                r#"ssl = true
2100[server]
2101[typedb]
2102address = "localhost:1729"
2103database = "db"
2104"#,
2105            ),
2106        ];
2107
2108        let dir = tempfile::tempdir().unwrap();
2109        for (index, (name, source)) in cases.into_iter().enumerate() {
2110            let path = dir.path().join(format!("ambiguous-{index}.toml"));
2111            std::fs::write(&path, source).unwrap();
2112            let error = RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None)
2113                .unwrap_err();
2114            assert!(
2115                error.to_string().contains("security-sensitive"),
2116                "{name} was not rejected by the downgrade guard: {error}"
2117            );
2118        }
2119    }
2120
2121    #[test]
2122    fn runtime_toml_errors_and_debug_never_retain_secret_source_values() {
2123        const SENTINEL: &str = "TB_SECRET_SENTINEL_7b4f";
2124
2125        let syntax = format!(
2126            "[server]\n[typedb]\naddress = \"localhost:1729\"\ndatabase = \"db\"\npassword = \"{SENTINEL}\n"
2127        );
2128        let wrong_password = format!(
2129            "[server]\n[typedb]\naddress = \"localhost:1729\"\ndatabase = \"db\"\npassword = [\"{SENTINEL}\"]\n"
2130        );
2131        let wrong_username = format!(
2132            "[server]\n[typedb]\naddress = \"localhost:1729\"\ndatabase = \"db\"\nusername = {{ secret = \"{SENTINEL}\" }}\n"
2133        );
2134        let unknown_security_key = format!(
2135            "[server]\n[typedb]\naddress = \"localhost:1729\"\ndatabase = \"db\"\ntls-{SENTINEL} = true\n"
2136        );
2137
2138        for (name, source) in [
2139            ("syntax", syntax),
2140            ("wrong password type", wrong_password),
2141            ("wrong username type", wrong_username),
2142            ("unknown security key", unknown_security_key),
2143        ] {
2144            let error = parse_runtime_wire(&source).expect_err(name);
2145            let rendered = format!("{error}\n{error:?}");
2146            assert!(!rendered.contains(SENTINEL), "{name}: {rendered}");
2147            assert!(
2148                error.source().is_none(),
2149                "{name} retained a source error that could expose TOML bytes"
2150            );
2151            assert!(
2152                rendered.contains("server configuration is invalid"),
2153                "{name}: {rendered}"
2154            );
2155        }
2156
2157        let dir = tempfile::tempdir().unwrap();
2158        let path = dir.path().join("server.toml");
2159        std::fs::write(
2160            &path,
2161            format!(
2162                "[server]\n[typedb]\naddress = \"admin:{SENTINEL}@localhost:1729\"\ndatabase = \"db\"\nusername = \"{SENTINEL}\"\npassword = \"{SENTINEL}\"\n"
2163            ),
2164        )
2165        .unwrap();
2166        let config = RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None)
2167            .expect("valid runtime config");
2168        let rendered = format!("{config:?}");
2169        assert!(!rendered.contains(SENTINEL), "{rendered}");
2170        assert!(rendered.contains("[REDACTED]"));
2171        let typedb_rendered = format!("{:?}", config.typedb);
2172        assert!(!typedb_rendered.contains(SENTINEL), "{typedb_rendered}");
2173        assert!(typedb_rendered.contains("[REDACTED]"));
2174
2175        let wire = parse_runtime_wire(&std::fs::read_to_string(path).unwrap()).unwrap();
2176        assert!(!format!("{wire:?}").contains(SENTINEL));
2177    }
2178
2179    #[test]
2180    fn unrelated_legacy_extension_keys_remain_tolerated() {
2181        let source = r#"[server]
2182vendor_extension = true
2183[typedb]
2184address = "localhost:1729"
2185database = "db"
2186[legacy]
2187keyboard_layout = "dvorak"
2188hassle = false
2189monkey = "capuchin"
2190uncertainty = 0.1
2191[legacy.transport]
2192key = "request-id"
2193cert = "third-party.pem"
2194private-key = "third-party.key"
2195trust-store = "third-party.pem"
2196[logging.labels]
2197key = "request-id"
2198cert = "classification"
2199api-key = "redacted"
2200cache-key = "server-v1"
2201"#;
2202        reject_ambiguous_security_keys(source).unwrap();
2203    }
2204
2205    #[test]
2206    fn released_top_level_and_known_namespace_extension_keys_remain_tolerated() {
2207        let source = r#"api-key = "redacted"
2208[server]
2209cache-key = "server-v1"
2210key = "request-id"
2211cert = "classification"
2212[typedb]
2213address = "localhost:1729"
2214database = "db"
2215api-key = "redacted"
2216certificate = "extension-metadata"
2217"#;
2218        reject_ambiguous_security_keys(source).unwrap();
2219    }
2220
2221    #[test]
2222    fn common_tls_typos_and_aliases_remain_downgrade_guarded() {
2223        for key in [
2224            "tls_enable",
2225            "enable_tls",
2226            "tls-root",
2227            "ssl-ca",
2228            "tls-cert-file",
2229            "key-path",
2230            "trust-store",
2231        ] {
2232            let source = format!(
2233                "[server]\n[typedb]\naddress = \"localhost:1729\"\ndatabase = \"db\"\n{key} = \"ignored\"\n"
2234            );
2235            let error = reject_ambiguous_security_keys(&source)
2236                .expect_err("TLS-shaped direct keys must not be ignored");
2237            assert!(
2238                error.to_string().contains("security-sensitive"),
2239                "{key}: {error}"
2240            );
2241        }
2242    }
2243
2244    #[test]
2245    fn env_overrides_typedb_section() {
2246        let mut config: ServerConfig = toml::from_str(FULL_CONFIG).unwrap();
2247        config
2248            .apply_env_overrides_from(|name| match name {
2249                "TYPEDB_ADDRESS" => Some("typedb:1729".to_string()),
2250                "TYPEDB_DATABASE" => Some("docker_db".to_string()),
2251                "TYPEDB_USERNAME" => Some("docker_user".to_string()),
2252                "TYPEDB_PASSWORD" => Some("docker_pass".to_string()),
2253                _ => None,
2254            })
2255            .unwrap();
2256
2257        assert_eq!(config.typedb.address, "typedb:1729");
2258        assert_eq!(config.typedb.database, "docker_db");
2259        assert_eq!(config.typedb.username, "docker_user");
2260        assert_eq!(config.typedb.password, "docker_pass");
2261    }
2262
2263    #[test]
2264    fn from_file_valid_minimal_config() {
2265        let dir = tempfile::tempdir().unwrap();
2266        let path = dir.path().join("server.toml");
2267        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
2268
2269        let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
2270        // Defaults should kick in
2271        assert_eq!(config.server.host, "0.0.0.0");
2272        assert_eq!(config.server.port, 8080);
2273        assert_eq!(config.typedb.username, "admin");
2274        assert_eq!(config.typedb.password, "password");
2275        assert_eq!(config.typedb.server_version, None);
2276        assert_eq!(config.schema.source_file, "");
2277        assert!(config.interceptors.enabled.is_empty());
2278        assert!(config.interceptors.audit_log.is_none());
2279        assert_eq!(config.logging.level, "info");
2280        assert_eq!(config.logging.format, "json");
2281    }
2282
2283    #[test]
2284    fn outbound_tls_truth_table_rejects_implicit_enablement() {
2285        let parse =
2286            |tls: Option<bool>, root: Option<&str>| outbound_tls_mode(tls, root.map(PathBuf::from));
2287        assert_eq!(parse(None, None).unwrap(), OutboundTlsMode::Disabled);
2288        assert_eq!(parse(Some(false), None).unwrap(), OutboundTlsMode::Disabled,);
2289        assert_eq!(
2290            parse(Some(true), None).unwrap(),
2291            OutboundTlsMode::NativeRoots,
2292        );
2293        assert!(parse(None, Some("root.pem")).is_err());
2294        assert!(parse(Some(false), Some("root.pem")).is_err());
2295        assert!(matches!(
2296            parse(Some(true), Some("root.pem")).unwrap(),
2297            OutboundTlsMode::CustomRootCa(path) if path == Path::new("root.pem")
2298        ));
2299    }
2300
2301    #[test]
2302    fn tls_paths_resolve_against_the_config_file() {
2303        let dir = tempfile::tempdir().unwrap();
2304        std::fs::create_dir(dir.path().join("certs")).unwrap();
2305        for name in ["ca.pem", "server.pem", "server.key"] {
2306            std::fs::write(dir.path().join("certs").join(name), "test-only material").unwrap();
2307        }
2308        let path = dir.path().join("server.toml");
2309        std::fs::write(
2310            &path,
2311            r#"[server]
2312[server.tls]
2313cert-path = "certs/server.pem"
2314key-path = "certs/server.key"
2315[typedb]
2316address = "localhost:1729"
2317database = "db"
2318tls = true
2319tls-root-ca = "certs/ca.pem"
2320"#,
2321        )
2322        .unwrap();
2323
2324        let config =
2325            RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
2326        assert!(matches!(
2327            config.typedb.tls_mode,
2328            OutboundTlsMode::CustomRootCa(ref path) if path.is_absolute()
2329        ));
2330        let inbound = config.inbound_tls.unwrap();
2331        assert!(inbound.cert_path.is_absolute());
2332        assert!(inbound.key_path.is_absolute());
2333    }
2334
2335    #[cfg(unix)]
2336    #[test]
2337    fn relative_tls_config_symlink_swap_cannot_mix_policy_and_base() {
2338        use std::os::unix::fs::symlink;
2339
2340        let dir = tempfile::tempdir().unwrap();
2341        let first = dir.path().join("first");
2342        let second = dir.path().join("second");
2343        std::fs::create_dir(&first).unwrap();
2344        std::fs::create_dir(&second).unwrap();
2345        for (root, database) in [(&first, "first-db"), (&second, "second-db")] {
2346            std::fs::write(root.join("root.pem"), database).unwrap();
2347            std::fs::write(
2348                root.join("server.toml"),
2349                format!(
2350                    r#"[server]
2351[typedb]
2352address = "localhost:1729"
2353database = "{database}"
2354tls = true
2355tls-root-ca = "root.pem"
2356"#
2357                ),
2358            )
2359            .unwrap();
2360        }
2361        let config_link = dir.path().join("server.toml");
2362        symlink(first.join("server.toml"), &config_link).unwrap();
2363        let replacement = second.join("server.toml");
2364        let link_for_swap = config_link.clone();
2365
2366        let config = RuntimeServerConfig::from_file_with_env_after_probe(
2367            config_link.to_str().unwrap(),
2368            |_| None,
2369            move || {
2370                std::fs::remove_file(&link_for_swap).unwrap();
2371                symlink(&replacement, &link_for_swap).unwrap();
2372            },
2373        )
2374        .unwrap();
2375
2376        assert_eq!(config.typedb.connection.database, "second-db");
2377        assert_eq!(
2378            config.typedb.tls_mode,
2379            OutboundTlsMode::CustomRootCa(second.join("root.pem").canonicalize().unwrap())
2380        );
2381        assert_eq!(
2382            config
2383                .typedb
2384                .custom_root_ca_snapshot
2385                .as_ref()
2386                .map(|material| material.bytes.as_ref()),
2387            Some(b"second-db".as_slice())
2388        );
2389    }
2390
2391    #[cfg(unix)]
2392    #[test]
2393    fn relative_tls_parent_swap_after_authorized_read_uses_one_directory_identity() {
2394        let dir = tempfile::tempdir().unwrap();
2395        let active = dir.path().join("active");
2396        let retained = dir.path().join("retained");
2397        std::fs::create_dir(&active).unwrap();
2398        std::fs::write(active.join("root.pem"), "original root").unwrap();
2399        std::fs::write(active.join("server.pem"), "original certificate").unwrap();
2400        std::fs::write(active.join("server.key"), "original private key").unwrap();
2401        let config_path = active.join("server.toml");
2402        std::fs::write(
2403            &config_path,
2404            r#"[server]
2405[server.tls]
2406cert-path = "server.pem"
2407key-path = "server.key"
2408[typedb]
2409address = "localhost:1729"
2410database = "original-db"
2411tls = true
2412tls-root-ca = "root.pem"
2413"#,
2414        )
2415        .unwrap();
2416        let active_for_swap = active.clone();
2417        let retained_for_swap = retained.clone();
2418
2419        let config = RuntimeServerConfig::from_file_with_env_after_probes(
2420            config_path.to_str().unwrap(),
2421            |_| None,
2422            || {},
2423            move || {
2424                std::fs::rename(&active_for_swap, &retained_for_swap).unwrap();
2425                std::fs::create_dir(&active_for_swap).unwrap();
2426                std::fs::write(active_for_swap.join("root.pem"), "replacement root").unwrap();
2427                std::fs::write(
2428                    active_for_swap.join("server.pem"),
2429                    "replacement certificate",
2430                )
2431                .unwrap();
2432                std::fs::write(
2433                    active_for_swap.join("server.key"),
2434                    "replacement private key",
2435                )
2436                .unwrap();
2437            },
2438        )
2439        .unwrap();
2440
2441        assert_eq!(config.typedb.connection.database, "original-db");
2442        assert_eq!(
2443            config
2444                .typedb
2445                .custom_root_ca_snapshot
2446                .as_ref()
2447                .map(|material| material.bytes.as_ref()),
2448            Some(b"original root".as_slice())
2449        );
2450        let inbound = config.inbound_tls.unwrap();
2451        let prepared = inbound.prepared.unwrap();
2452        assert_eq!(
2453            prepared
2454                .certificate
2455                .as_ref()
2456                .map(|material| material.bytes.as_ref()),
2457            Some(b"original certificate".as_slice())
2458        );
2459        assert_eq!(
2460            prepared
2461                .private_key
2462                .as_ref()
2463                .map(|material| material.bytes.as_ref()),
2464            Some(b"original private key".as_slice())
2465        );
2466    }
2467
2468    #[cfg(unix)]
2469    #[test]
2470    fn parent_relative_tls_uses_the_retained_ancestor_after_parent_swap() {
2471        let dir = tempfile::tempdir().unwrap();
2472        let active_parent = dir.path().join("active-parent");
2473        let config_dir = active_parent.join("config");
2474        let retained_parent = dir.path().join("retained-parent");
2475        std::fs::create_dir_all(&config_dir).unwrap();
2476        std::fs::write(active_parent.join("root.pem"), "original ancestor root").unwrap();
2477        let config_path = config_dir.join("server.toml");
2478        std::fs::write(
2479            &config_path,
2480            r#"[server]
2481[typedb]
2482address = "localhost:1729"
2483database = "original-db"
2484tls = true
2485tls-root-ca = "../root.pem"
2486"#,
2487        )
2488        .unwrap();
2489        let active_for_swap = active_parent.clone();
2490        let retained_for_swap = retained_parent.clone();
2491
2492        let config = RuntimeServerConfig::from_file_with_env_after_probes(
2493            config_path.to_str().unwrap(),
2494            |_| None,
2495            || {},
2496            move || {
2497                std::fs::rename(&active_for_swap, &retained_for_swap).unwrap();
2498                std::fs::create_dir(&active_for_swap).unwrap();
2499                std::fs::write(
2500                    active_for_swap.join("root.pem"),
2501                    "replacement ancestor root",
2502                )
2503                .unwrap();
2504            },
2505        )
2506        .unwrap();
2507
2508        assert_eq!(config.typedb.connection.database, "original-db");
2509        assert_eq!(
2510            config
2511                .typedb
2512                .custom_root_ca_snapshot
2513                .as_ref()
2514                .map(|material| material.bytes.as_ref()),
2515            Some(b"original ancestor root".as_slice())
2516        );
2517    }
2518
2519    #[test]
2520    fn plaintext_config_does_not_require_a_post_read_path_lookup() {
2521        let dir = tempfile::tempdir().unwrap();
2522        let path = dir.path().join("server.toml");
2523        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
2524        let remove_after_read = path.clone();
2525
2526        let config = RuntimeServerConfig::from_file_with_env_after_probe(
2527            path.to_str().unwrap(),
2528            |_| None,
2529            move || std::fs::remove_file(remove_after_read).unwrap(),
2530        )
2531        .unwrap();
2532
2533        assert_eq!(config.typedb.connection.database, "mydb");
2534        assert_eq!(config.typedb.tls_mode, OutboundTlsMode::Disabled);
2535    }
2536
2537    #[test]
2538    fn absolute_tls_paths_do_not_require_a_post_read_config_lookup() {
2539        let dir = tempfile::tempdir().unwrap();
2540        let root = dir.path().join("root.pem");
2541        std::fs::write(&root, "root material").unwrap();
2542        let path = dir.path().join("server.toml");
2543        std::fs::write(
2544            &path,
2545            format!(
2546                r#"[server]
2547[typedb]
2548address = "localhost:1729"
2549database = "db"
2550tls = true
2551tls-root-ca = {root:?}
2552"#,
2553                root = root.to_str().unwrap()
2554            ),
2555        )
2556        .unwrap();
2557        let remove_after_read = path.clone();
2558
2559        let config = RuntimeServerConfig::from_file_with_env_after_probe(
2560            path.to_str().unwrap(),
2561            |_| None,
2562            move || std::fs::remove_file(remove_after_read).unwrap(),
2563        )
2564        .unwrap();
2565
2566        assert_eq!(
2567            config.typedb.tls_mode,
2568            OutboundTlsMode::CustomRootCa(root.canonicalize().unwrap())
2569        );
2570        assert_eq!(
2571            config
2572                .typedb
2573                .custom_root_ca_snapshot
2574                .as_ref()
2575                .map(|material| material.bytes.as_ref()),
2576            Some(b"root material".as_slice())
2577        );
2578    }
2579
2580    #[test]
2581    fn absolute_tls_material_survives_parent_replacement_after_config_load() {
2582        let dir = tempfile::tempdir().unwrap();
2583        let active = dir.path().join("active");
2584        let retained = dir.path().join("retained");
2585        std::fs::create_dir(&active).unwrap();
2586        let root = active.join("root.pem");
2587        let certificate = active.join("server.pem");
2588        let private_key = active.join("server.key");
2589        std::fs::write(&root, "original root").unwrap();
2590        std::fs::write(&certificate, "original certificate").unwrap();
2591        std::fs::write(&private_key, "original private key").unwrap();
2592        let config_path = dir.path().join("server.toml");
2593        std::fs::write(
2594            &config_path,
2595            format!(
2596                r#"[server]
2597[server.tls]
2598cert-path = {certificate:?}
2599key-path = {private_key:?}
2600[typedb]
2601address = "localhost:1729"
2602database = "db"
2603tls = true
2604tls-root-ca = {root:?}
2605"#,
2606            ),
2607        )
2608        .unwrap();
2609
2610        let config =
2611            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
2612                .unwrap();
2613        std::fs::rename(&active, &retained).unwrap();
2614        std::fs::create_dir(&active).unwrap();
2615        std::fs::write(active.join("root.pem"), "replacement root").unwrap();
2616        std::fs::write(active.join("server.pem"), "replacement certificate").unwrap();
2617        std::fs::write(active.join("server.key"), "replacement private key").unwrap();
2618
2619        assert_eq!(
2620            config
2621                .typedb
2622                .custom_root_ca_snapshot
2623                .as_ref()
2624                .map(|material| material.bytes.as_ref()),
2625            Some(b"original root".as_slice())
2626        );
2627        let prepared = config.inbound_tls.unwrap().prepared.unwrap();
2628        assert_eq!(
2629            prepared
2630                .certificate
2631                .as_ref()
2632                .map(|material| material.bytes.as_ref()),
2633            Some(b"original certificate".as_slice())
2634        );
2635        assert_eq!(
2636            prepared
2637                .private_key
2638                .as_ref()
2639                .map(|material| material.bytes.as_ref()),
2640            Some(b"original private key".as_slice())
2641        );
2642    }
2643
2644    #[test]
2645    fn outbound_root_contradiction_fails_before_file_access() {
2646        let dir = tempfile::tempdir().unwrap();
2647        let path = dir.path().join("server.toml");
2648        std::fs::write(
2649            &path,
2650            r#"[server]
2651[typedb]
2652address = "localhost:1729"
2653database = "db"
2654tls = false
2655tls-root-ca = "missing.pem"
2656"#,
2657        )
2658        .unwrap();
2659        let error = RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None)
2660            .expect_err("contradictory policy must fail before reading the path");
2661        assert!(error.to_string().contains("contradicts"));
2662    }
2663
2664    #[test]
2665    fn oversized_tls_material_fails_before_identity_or_provider_construction() {
2666        let dir = tempfile::tempdir().unwrap();
2667        let root = dir.path().join("root.pem");
2668        let file = std::fs::File::create(&root).unwrap();
2669        file.set_len(MAX_TLS_MATERIAL_BYTES + 1).unwrap();
2670        // Close the writable fixture handle before loading: the capture
2671        // opens the file denying concurrent writers, so a live writer
2672        // handle on Windows is a sharing violation, not a size failure.
2673        drop(file);
2674        let path = dir.path().join("server.toml");
2675        std::fs::write(
2676            &path,
2677            r#"[server]
2678[typedb]
2679address = "localhost:1729"
2680database = "db"
2681tls = true
2682tls-root-ca = "root.pem"
2683"#,
2684        )
2685        .unwrap();
2686        let error = RuntimeServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None)
2687            .expect_err("oversized trust material must fail during config loading");
2688        assert!(error.to_string().contains("no larger than 1 MiB"));
2689    }
2690
2691    #[tokio::test]
2692    async fn malformed_inbound_identity_fails_before_bind() {
2693        let dir = tempfile::tempdir().unwrap();
2694        let cert = dir.path().join("server.pem");
2695        let key = dir.path().join("server.key");
2696        std::fs::write(&cert, "not a certificate").unwrap();
2697        std::fs::write(&key, "not a key").unwrap();
2698        let tls = InboundTlsSection::from_paths(cert, key);
2699        assert!(tls.load().await.is_err());
2700    }
2701
2702    #[tokio::test]
2703    async fn inbound_identity_load_rechecks_the_open_handle_byte_limit() {
2704        let dir = tempfile::tempdir().unwrap();
2705        // load() walks parent components without following symlinks; the
2706        // ambient temp directory may sit behind symlinked components
2707        // (macOS /var), which would fail before the byte-limit check.
2708        let root = dir.path().canonicalize().unwrap();
2709        let cert = root.join("server.pem");
2710        let key = root.join("server.key");
2711        let file = std::fs::File::create(&cert).unwrap();
2712        file.set_len(MAX_TLS_MATERIAL_BYTES + 1).unwrap();
2713        std::fs::write(&key, "not a key").unwrap();
2714        let tls = InboundTlsSection::from_paths(cert, key);
2715        let error = tls
2716            .load()
2717            .await
2718            .expect_err("oversized identity must fail bounded");
2719        assert!(error.to_string().contains("no larger than 1 MiB"));
2720    }
2721
2722    #[cfg(unix)]
2723    #[tokio::test]
2724    async fn relative_inbound_identity_ignores_a_post_capture_symlink_swap() {
2725        use std::os::unix::fs::symlink;
2726
2727        let identity = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap();
2728        let dir = tempfile::tempdir().unwrap();
2729        let cert = dir.path().join("server.pem");
2730        let replacement = dir.path().join("replacement.pem");
2731        let key = dir.path().join("server.key");
2732        std::fs::write(&cert, identity.cert.pem()).unwrap();
2733        std::fs::write(&replacement, "attacker-controlled replacement").unwrap();
2734        std::fs::write(&key, identity.key_pair.serialize_pem()).unwrap();
2735        let config_path = dir.path().join("server.toml");
2736        std::fs::write(
2737            &config_path,
2738            r#"[server]
2739[server.tls]
2740cert-path = "server.pem"
2741key-path = "server.key"
2742[typedb]
2743address = "localhost:1729"
2744database = "db"
2745"#,
2746        )
2747        .unwrap();
2748        let config =
2749            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
2750                .unwrap();
2751        let tls = config.inbound_tls.unwrap();
2752
2753        std::fs::remove_file(&cert).unwrap();
2754        symlink(&replacement, &cert).unwrap();
2755        tls.load()
2756            .await
2757            .expect("relative identity loading must consume the captured certificate bytes");
2758    }
2759
2760    #[cfg(unix)]
2761    #[tokio::test]
2762    async fn relative_inbound_identity_ignores_a_post_capture_parent_swap() {
2763        use std::os::unix::fs::symlink;
2764
2765        let identity = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap();
2766        let dir = tempfile::tempdir().unwrap();
2767        let identity_dir = dir.path().join("identity");
2768        let replacement_dir = dir.path().join("replacement");
2769        std::fs::create_dir(&identity_dir).unwrap();
2770        std::fs::create_dir(&replacement_dir).unwrap();
2771        std::fs::write(identity_dir.join("server.pem"), identity.cert.pem()).unwrap();
2772        std::fs::write(
2773            identity_dir.join("server.key"),
2774            identity.key_pair.serialize_pem(),
2775        )
2776        .unwrap();
2777        std::fs::write(
2778            replacement_dir.join("server.pem"),
2779            "attacker-controlled certificate",
2780        )
2781        .unwrap();
2782        std::fs::write(
2783            replacement_dir.join("server.key"),
2784            "attacker-controlled private key",
2785        )
2786        .unwrap();
2787        let config_path = dir.path().join("server.toml");
2788        std::fs::write(
2789            &config_path,
2790            r#"[server]
2791[server.tls]
2792cert-path = "identity/server.pem"
2793key-path = "identity/server.key"
2794[typedb]
2795address = "localhost:1729"
2796database = "db"
2797"#,
2798        )
2799        .unwrap();
2800        let config =
2801            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
2802                .unwrap();
2803        let tls = config.inbound_tls.unwrap();
2804
2805        std::fs::rename(&identity_dir, dir.path().join("identity-original")).unwrap();
2806        symlink(&replacement_dir, &identity_dir).unwrap();
2807        tls.load()
2808            .await
2809            .expect("relative identity loading must consume the captured identity bytes");
2810    }
2811
2812    #[tokio::test]
2813    async fn relative_inbound_identity_rejects_a_mutated_diagnostic_path() {
2814        let dir = tempfile::tempdir().unwrap();
2815        std::fs::write(dir.path().join("server.pem"), "captured certificate").unwrap();
2816        std::fs::write(dir.path().join("server.key"), "captured private key").unwrap();
2817        let config_path = dir.path().join("server.toml");
2818        std::fs::write(
2819            &config_path,
2820            r#"[server]
2821[server.tls]
2822cert-path = "server.pem"
2823key-path = "server.key"
2824[typedb]
2825address = "localhost:1729"
2826database = "db"
2827"#,
2828        )
2829        .unwrap();
2830        let config =
2831            RuntimeServerConfig::from_file_with_env(config_path.to_str().unwrap(), |_| None)
2832                .unwrap();
2833        let mut tls = config.inbound_tls.unwrap();
2834        tls.cert_path = dir.path().join("mutated.pem");
2835
2836        let error = tls
2837            .load()
2838            .await
2839            .expect_err("captured certificate bytes must remain bound to their exact path");
2840        assert!(
2841            error
2842                .to_string()
2843                .contains("server.tls.cert-path changed after"),
2844            "{error}"
2845        );
2846    }
2847
2848    #[cfg(unix)]
2849    #[test]
2850    fn inbound_identity_fifo_rejects_without_blocking() {
2851        use std::sync::mpsc;
2852        use std::time::Duration;
2853
2854        let dir = tempfile::tempdir().unwrap();
2855        // load() walks parent components without following symlinks; the
2856        // ambient temp directory may sit behind symlinked components
2857        // (macOS /var), which would fail before the regular-file check.
2858        let root = dir.path().canonicalize().unwrap();
2859        let fifo = root.join("server.pem");
2860        let status = std::process::Command::new("mkfifo")
2861            .arg(&fifo)
2862            .status()
2863            .expect("POSIX mkfifo is available");
2864        assert!(status.success(), "mkfifo failed: {status}");
2865        let key = root.join("server.key");
2866        std::fs::write(&key, "not reached").unwrap();
2867        let tls = InboundTlsSection::from_paths(fifo, key);
2868        let (sender, receiver) = mpsc::sync_channel(1);
2869        std::thread::spawn(move || {
2870            let runtime = tokio::runtime::Builder::new_current_thread()
2871                .enable_all()
2872                .build()
2873                .unwrap();
2874            sender
2875                .send(
2876                    runtime
2877                        .block_on(tls.load())
2878                        .map(|_| ())
2879                        .map_err(|error| error.to_string()),
2880                )
2881                .ok();
2882        });
2883        let result = receiver
2884            .recv_timeout(Duration::from_secs(2))
2885            .expect("opening a FIFO must never block the process");
2886        let error = result.expect_err("a FIFO is not valid identity material");
2887        assert!(error.to_string().contains("must name a regular file"));
2888    }
2889
2890    #[tokio::test]
2891    async fn mismatched_inbound_identity_fails_before_bind() {
2892        let first = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap();
2893        let second = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap();
2894        let dir = tempfile::tempdir().unwrap();
2895        let cert = dir.path().join("server.pem");
2896        let key = dir.path().join("server.key");
2897        std::fs::write(&cert, first.cert.pem()).unwrap();
2898        std::fs::write(&key, second.key_pair.serialize_pem()).unwrap();
2899        let tls = InboundTlsSection::from_paths(cert, key);
2900        assert!(tls.load().await.is_err());
2901    }
2902
2903    #[cfg(unix)]
2904    #[test]
2905    fn runtime_config_fifo_rejects_without_blocking() {
2906        use std::sync::mpsc;
2907        use std::time::Duration;
2908
2909        let dir = tempfile::tempdir().unwrap();
2910        let fifo = dir.path().join("server.toml");
2911        let status = std::process::Command::new("mkfifo")
2912            .arg(&fifo)
2913            .status()
2914            .expect("POSIX mkfifo is available");
2915        assert!(status.success(), "mkfifo failed: {status}");
2916        let (sender, receiver) = mpsc::sync_channel(1);
2917        std::thread::spawn(move || {
2918            sender
2919                .send(read_runtime_config_path(&fifo).map_err(|error| error.to_string()))
2920                .ok();
2921        });
2922        let result = receiver
2923            .recv_timeout(Duration::from_secs(2))
2924            .expect("opening a configuration FIFO must never block the process");
2925        let error = result.expect_err("a FIFO is not a valid server configuration");
2926        assert!(error.contains("regular file"), "{error}");
2927    }
2928
2929    #[test]
2930    fn oversized_runtime_config_is_rejected_from_same_handle_metadata() {
2931        let dir = tempfile::tempdir().unwrap();
2932        let path = dir.path().join("server.toml");
2933        let file = std::fs::File::create(&path).unwrap();
2934        file.set_len(MAX_SERVER_CONFIG_BYTES + 1).unwrap();
2935        // Close the writable fixture handle before loading: the reader
2936        // opens the file denying concurrent writers, so a live writer
2937        // handle on Windows is a sharing violation, not a size failure.
2938        drop(file);
2939
2940        let error = read_runtime_config_path(&path).unwrap_err();
2941        assert!(error.to_string().contains("no larger than 1 MiB"));
2942    }
2943
2944    #[test]
2945    fn non_regular_runtime_config_path_is_rejected() {
2946        let dir = tempfile::tempdir().unwrap();
2947        let error = read_runtime_config_path(dir.path()).unwrap_err();
2948        assert!(error.to_string().contains("regular file"), "{error}");
2949    }
2950
2951    #[test]
2952    fn non_regular_absolute_configured_file_is_rejected() {
2953        let dir = tempfile::tempdir().unwrap();
2954        let error = match capture_absolute_configured_file(dir.path(), "typedb.tls-root-ca") {
2955            Ok(_) => panic!("a directory must not resolve as configured TLS material"),
2956            Err(error) => error,
2957        };
2958        assert!(error.to_string().contains("regular file"), "{error}");
2959    }
2960
2961    #[cfg(feature = "v2-query")]
2962    #[test]
2963    fn non_regular_absolute_declared_schema_is_rejected() {
2964        let dir = tempfile::tempdir().unwrap();
2965        // The capture walks parent components without following symlinks;
2966        // the ambient temp directory may sit behind symlinked components
2967        // (macOS /var), so hand it an already-physical path.
2968        let root = dir.path().canonicalize().unwrap();
2969        let error = capture_absolute_declared_schema_file(&root)
2970            .expect_err("a directory must not resolve as a declared schema");
2971        assert!(error.to_string().contains("regular file"), "{error}");
2972    }
2973
2974    #[test]
2975    fn runtime_config_growth_after_metadata_is_rejected_by_bounded_read() {
2976        let dir = tempfile::tempdir().unwrap();
2977        let path = dir.path().join("server.toml");
2978        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
2979        let file = std::fs::OpenOptions::new()
2980            .read(true)
2981            .write(true)
2982            .open(&path)
2983            .unwrap();
2984        let grow = file.try_clone().unwrap();
2985
2986        let error = read_runtime_config_handle_after_inspect(file, move || {
2987            grow.set_len(MAX_SERVER_CONFIG_BYTES + 1).unwrap();
2988        })
2989        .unwrap_err();
2990        assert!(error.to_string().contains("no larger than 1 MiB"));
2991    }
2992
2993    #[test]
2994    fn same_size_runtime_config_rewrite_between_reads_is_rejected() {
2995        use std::io::{Seek as _, Write as _};
2996
2997        let dir = tempfile::tempdir().unwrap();
2998        let path = dir.path().join("server.toml");
2999        let original = MINIMAL_CONFIG.replace("mydb", "aaaa");
3000        let replacement = MINIMAL_CONFIG.replace("mydb", "bbbb");
3001        assert_eq!(original.len(), replacement.len());
3002        std::fs::write(&path, original).unwrap();
3003        let reader = std::fs::File::open(&path).unwrap();
3004        let mut writer = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
3005
3006        let error = read_runtime_config_handle_with_hooks(
3007            reader,
3008            || {},
3009            move || {
3010                writer.seek(SeekFrom::Start(0)).unwrap();
3011                writer.write_all(replacement.as_bytes()).unwrap();
3012                writer.flush().unwrap();
3013            },
3014        )
3015        .unwrap_err();
3016        assert!(error.to_string().contains("changed while"), "{error}");
3017    }
3018
3019    #[test]
3020    fn from_file_missing_file() {
3021        let result = ServerConfig::from_file("/nonexistent/path/server.toml");
3022        assert!(result.is_err());
3023    }
3024
3025    #[test]
3026    fn from_file_invalid_toml() {
3027        let dir = tempfile::tempdir().unwrap();
3028        let path = dir.path().join("bad.toml");
3029        std::fs::write(&path, "this is not valid toml {{{}}}").unwrap();
3030
3031        let result = ServerConfig::from_file(path.to_str().unwrap());
3032        assert!(result.is_err());
3033    }
3034
3035    #[test]
3036    fn from_file_missing_required_typedb_section() {
3037        let dir = tempfile::tempdir().unwrap();
3038        let path = dir.path().join("incomplete.toml");
3039        std::fs::write(&path, "[server]\n").unwrap();
3040
3041        let result = ServerConfig::from_file(path.to_str().unwrap());
3042        assert!(result.is_err());
3043    }
3044
3045    #[test]
3046    fn from_file_missing_required_typedb_fields() {
3047        let dir = tempfile::tempdir().unwrap();
3048        let path = dir.path().join("incomplete.toml");
3049        std::fs::write(&path, "[server]\n[typedb]\n").unwrap();
3050
3051        let result = ServerConfig::from_file(path.to_str().unwrap());
3052        assert!(result.is_err()); // address and database are required
3053    }
3054
3055    // --- Default function tests ---
3056
3057    #[test]
3058    fn default_host_value() {
3059        assert_eq!(default_host(), "0.0.0.0");
3060    }
3061
3062    #[test]
3063    fn default_port_value() {
3064        assert_eq!(default_port(), 8080);
3065    }
3066
3067    #[test]
3068    fn default_username_value() {
3069        assert_eq!(default_username(), "admin");
3070    }
3071
3072    #[test]
3073    fn default_password_value() {
3074        assert_eq!(default_password(), "password");
3075    }
3076
3077    #[test]
3078    fn default_log_level_value() {
3079        assert_eq!(default_log_level(), "info");
3080    }
3081
3082    #[test]
3083    fn default_log_format_value() {
3084        assert_eq!(default_log_format(), "json");
3085    }
3086
3087    #[test]
3088    fn default_audit_output_value() {
3089        assert_eq!(default_audit_output(), "stdout");
3090    }
3091
3092    // --- LoggingSection default ---
3093
3094    #[test]
3095    fn logging_section_default() {
3096        let logging = LoggingSection::default();
3097        assert_eq!(logging.level, "info");
3098        assert_eq!(logging.format, "json");
3099    }
3100
3101    // --- Serde deserialization edge cases ---
3102
3103    #[test]
3104    fn server_section_custom_host_default_port() {
3105        let toml = r#"
3106[server]
3107host = "192.168.1.1"
3108
3109[typedb]
3110address = "localhost:1729"
3111database = "db"
3112"#;
3113        let config: ServerConfig = toml::from_str(toml).unwrap();
3114        assert_eq!(config.server.host, "192.168.1.1");
3115        assert_eq!(config.server.port, 8080); // default
3116    }
3117
3118    #[test]
3119    fn server_section_custom_port_default_host() {
3120        let toml = r#"
3121[server]
3122port = 3000
3123
3124[typedb]
3125address = "localhost:1729"
3126database = "db"
3127"#;
3128        let config: ServerConfig = toml::from_str(toml).unwrap();
3129        assert_eq!(config.server.host, "0.0.0.0"); // default
3130        assert_eq!(config.server.port, 3000);
3131    }
3132
3133    #[test]
3134    fn typedb_section_custom_credentials() {
3135        let toml = r#"
3136[server]
3137
3138[typedb]
3139address = "remote:1729"
3140database = "prod"
3141username = "superuser"
3142password = "hunter2"
3143"#;
3144        let config: ServerConfig = toml::from_str(toml).unwrap();
3145        assert_eq!(config.typedb.username, "superuser");
3146        assert_eq!(config.typedb.password, "hunter2");
3147    }
3148
3149    #[test]
3150    fn schema_section_default_when_missing() {
3151        let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
3152        assert_eq!(config.schema.source_file, "");
3153    }
3154
3155    #[test]
3156    fn schema_section_with_file() {
3157        let toml = r#"
3158[server]
3159[typedb]
3160address = "localhost:1729"
3161database = "db"
3162[schema]
3163source_file = "my_schema.tql"
3164"#;
3165        let config: ServerConfig = toml::from_str(toml).unwrap();
3166        assert_eq!(config.schema.source_file, "my_schema.tql");
3167    }
3168
3169    #[test]
3170    fn interceptors_enabled_empty_by_default() {
3171        let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
3172        assert!(config.interceptors.enabled.is_empty());
3173        assert!(config.interceptors.audit_log.is_none());
3174    }
3175
3176    #[test]
3177    fn interceptors_enabled_without_audit_config() {
3178        let toml = r#"
3179[server]
3180[typedb]
3181address = "localhost:1729"
3182database = "db"
3183[interceptors]
3184enabled = ["audit-log"]
3185"#;
3186        let config: ServerConfig = toml::from_str(toml).unwrap();
3187        assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
3188        assert!(config.interceptors.audit_log.is_none());
3189    }
3190
3191    #[test]
3192    fn interceptors_with_audit_config() {
3193        let toml = r#"
3194[server]
3195[typedb]
3196address = "localhost:1729"
3197database = "db"
3198[interceptors]
3199enabled = ["audit-log"]
3200[interceptors.audit-log]
3201output = "file"
3202file_path = "/var/log/audit.jsonl"
3203"#;
3204        let config: ServerConfig = toml::from_str(toml).unwrap();
3205        let audit = config.interceptors.audit_log.unwrap();
3206        assert_eq!(audit.output, "file");
3207        assert_eq!(audit.file_path, "/var/log/audit.jsonl");
3208    }
3209
3210    #[test]
3211    fn audit_log_config_defaults() {
3212        let toml = r#"
3213[server]
3214[typedb]
3215address = "localhost:1729"
3216database = "db"
3217[interceptors]
3218enabled = ["audit-log"]
3219[interceptors.audit-log]
3220"#;
3221        let config: ServerConfig = toml::from_str(toml).unwrap();
3222        let audit = config.interceptors.audit_log.unwrap();
3223        assert_eq!(audit.output, "stdout"); // default
3224        assert_eq!(audit.file_path, ""); // default
3225    }
3226
3227    #[test]
3228    fn extra_fields_ignored() {
3229        let toml = r#"
3230[server]
3231host = "0.0.0.0"
3232unknown_field = "ignored"
3233
3234[typedb]
3235address = "localhost:1729"
3236database = "db"
3237"#;
3238        // toml crate with serde ignores unknown fields by default
3239        let result: Result<ServerConfig, _> = toml::from_str(toml);
3240        assert!(result.is_ok());
3241    }
3242
3243    #[test]
3244    fn multiple_interceptors_enabled() {
3245        let toml = r#"
3246[server]
3247[typedb]
3248address = "localhost:1729"
3249database = "db"
3250[interceptors]
3251enabled = ["audit-log", "rate-limiter", "custom"]
3252"#;
3253        let config: ServerConfig = toml::from_str(toml).unwrap();
3254        assert_eq!(config.interceptors.enabled.len(), 3);
3255    }
3256
3257    // --- TYPEDB_HTTP_PORT env override ---
3258
3259    #[test]
3260    fn env_overrides_http_port() {
3261        let dir = tempfile::tempdir().unwrap();
3262        let path = dir.path().join("server.toml");
3263        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
3264
3265        let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |name| {
3266            if name == "TYPEDB_HTTP_PORT" {
3267                Some("9123".to_string())
3268            } else {
3269                None
3270            }
3271        })
3272        .unwrap();
3273
3274        assert_eq!(config.typedb.http_port, 9123);
3275    }
3276
3277    #[test]
3278    fn env_overrides_server_version() {
3279        let dir = tempfile::tempdir().unwrap();
3280        let path = dir.path().join("server.toml");
3281        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
3282
3283        let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |name| {
3284            if name == "TYPEDB_SERVER_VERSION" {
3285                Some("3.10.4".to_string())
3286            } else {
3287                None
3288            }
3289        })
3290        .unwrap();
3291
3292        assert_eq!(config.typedb.server_version.as_deref(), Some("3.10.4"));
3293    }
3294
3295    #[test]
3296    fn env_invalid_http_port_errors() {
3297        let dir = tempfile::tempdir().unwrap();
3298        let path = dir.path().join("server.toml");
3299        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
3300
3301        let result = ServerConfig::from_file_with_env(path.to_str().unwrap(), |name| {
3302            if name == "TYPEDB_HTTP_PORT" {
3303                Some("not-a-port".to_string())
3304            } else {
3305                None
3306            }
3307        });
3308
3309        assert!(
3310            result.is_err(),
3311            "invalid TYPEDB_HTTP_PORT must return an error"
3312        );
3313        let msg = result.unwrap_err().to_string();
3314        assert!(
3315            msg.contains("TYPEDB_HTTP_PORT"),
3316            "error message must mention TYPEDB_HTTP_PORT: {msg}"
3317        );
3318    }
3319
3320    #[test]
3321    fn default_http_port_equals_ssot() {
3322        // Pins the server default against the core SSOT constant so any
3323        // divergence between the two fails at test time.
3324        use super::core_version;
3325        assert_eq!(
3326            default_http_port(),
3327            core_version::DEFAULT_HTTP_PORT,
3328            "server default_http_port() must equal core DEFAULT_HTTP_PORT"
3329        );
3330    }
3331}