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