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 let location = error
47 .span()
48 .and_then(|span| source_line_column(content, span.start));
49 Self { kind, location }
50 }
51
52 const fn unknown_security_key() -> Self {
53 Self {
54 kind: RuntimeConfigParseErrorKind::UnknownSecurityKey,
55 location: None,
56 }
57 }
58}
59
60impl fmt::Display for RuntimeConfigParseError {
61 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62 let reason = match self.kind {
63 RuntimeConfigParseErrorKind::Syntax => "TOML syntax",
64 RuntimeConfigParseErrorKind::ValueShape => "value shape",
65 RuntimeConfigParseErrorKind::UnknownSecurityKey => "unknown security-sensitive key",
66 };
67 write!(formatter, "server configuration is invalid ({reason})")?;
68 if let Some((line, column)) = self.location {
69 write!(formatter, " at line {line}, column {column}")?;
70 }
71 Ok(())
72 }
73}
74
75impl fmt::Debug for RuntimeConfigParseError {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 fmt::Display::fmt(self, formatter)
78 }
79}
80
81impl std::error::Error for RuntimeConfigParseError {}
82
83fn source_line_column(content: &str, byte_offset: usize) -> Option<(usize, usize)> {
84 if byte_offset > content.len() || !content.is_char_boundary(byte_offset) {
85 return None;
86 }
87 let prefix = &content[..byte_offset];
88 let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
89 let column = prefix
90 .rsplit_once('\n')
91 .map_or(prefix, |(_, tail)| tail)
92 .chars()
93 .count()
94 + 1;
95 Some((line, column))
96}
97
98#[derive(Debug, Deserialize)]
99pub struct ServerConfig {
101 pub server: ServerSection,
103 pub typedb: TypeDBSection,
105 #[serde(default)]
107 pub schema: SchemaSection,
108 #[serde(default)]
110 pub interceptors: InterceptorsSection,
111 #[serde(default)]
113 pub logging: LoggingSection,
114}
115
116pub struct RuntimeServerConfig {
124 pub server: ServerSection,
126 pub typedb: SecureTypeDBSection,
128 pub schema: SchemaSection,
130 pub interceptors: InterceptorsSection,
132 pub logging: LoggingSection,
134 pub inbound_tls: Option<InboundTlsSection>,
136 pub v2: V2Section,
138}
139
140pub struct SecureTypeDBSection {
153 pub(crate) connection: TypeDBSection,
155 pub tls_mode: OutboundTlsMode,
157 #[cfg_attr(not(feature = "typedb"), allow(dead_code))]
161 pub(crate) custom_root_ca_snapshot: Option<CapturedConfiguredMaterial>,
162 #[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 #[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 #[must_use]
215 pub fn database(&self) -> &str {
216 &self.connection.database
217 }
218}
219
220#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
222#[serde(rename_all = "snake_case")]
223pub enum V2AuthorityMode {
224 #[default]
226 Managed,
227 QueryOnly,
229}
230
231#[derive(Debug, Default, Deserialize)]
237pub struct V2Section {
238 #[serde(default)]
240 pub enabled: bool,
241 #[serde(default)]
243 pub schema_authority_file: String,
244 #[serde(default)]
246 pub authority_mode: V2AuthorityMode,
247}
248
249#[derive(Debug, Deserialize)]
250pub struct ServerSection {
252 #[serde(default = "default_host")]
254 pub host: String,
255 #[serde(default = "default_port")]
257 pub port: u16,
258}
259
260#[derive(Clone, Debug, Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct InboundTlsSection {
264 #[serde(rename = "cert-path")]
266 pub cert_path: PathBuf,
267 #[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 #[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 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 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)]
470pub struct TypeDBSection {
472 pub address: String,
474 pub database: String,
476 #[serde(default = "default_username")]
478 pub username: String,
479 #[serde(default = "default_password")]
481 pub password: String,
482 #[serde(default = "default_http_port")]
485 pub http_port: u16,
486 #[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#[derive(Clone, Debug, Eq, PartialEq)]
508pub enum OutboundTlsMode {
509 Disabled,
511 NativeRoots,
513 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)]
671pub struct SchemaSection {
673 #[serde(default)]
675 pub source_file: String,
676}
677
678#[derive(Debug, Default, Deserialize)]
679pub struct InterceptorsSection {
681 #[serde(default)]
683 pub enabled: Vec<String>,
684 #[serde(default, rename = "audit-log")]
686 pub audit_log: Option<AuditLogConfig>,
687}
688
689#[derive(Debug, Clone, Deserialize)]
690pub struct AuditLogConfig {
692 #[serde(default = "default_audit_output")]
694 pub output: String,
695 #[serde(default)]
697 pub file_path: String,
698}
699
700#[derive(Debug, Deserialize)]
701pub struct LoggingSection {
703 #[serde(default = "default_log_level")]
705 pub level: String,
706 #[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 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 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 #[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 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 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 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 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 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 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 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 options.maybe_dir(true);
1233 #[cfg(unix)]
1234 options.nonblock(true);
1235 #[cfg(windows)]
1236 {
1237 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 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 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
1462fn 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 #[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 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 #[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 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 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 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 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 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 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 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()); }
3094
3095 #[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 #[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 #[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); }
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"); 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"); assert_eq!(audit.file_path, ""); }
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 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 #[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 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}