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