1use loonfs::RuntimeCacheConfig;
5use loonfs_grep::GrepWorkerConfig;
6use loonfs_objectstore::{ConfiguredObjectStore, SecretString, StoreConfigError};
7use serde::Deserialize;
8use std::env;
9use std::fs;
10use std::net::SocketAddr;
11use std::path::Path;
12use thiserror::Error;
13
14pub use loonfs_objectstore::StoreConfig;
15
16const AUTH_TOKEN_ENV: &str = "LOONFS_AUTH_TOKEN";
18const CONTENT_TOKEN_SECRET_ENV: &str = "LOONFS_CONTENT_TOKEN_SECRET";
20
21#[derive(Debug, Clone, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ServerConfig {
35 pub bind: String,
36 pub auth_token: Option<SecretString>,
37 #[serde(default)]
40 pub content_token_secret: SecretString,
41 pub writer_id: String,
42 #[serde(default)]
43 pub runtime_cache: RuntimeCacheConfigOverrides,
44 #[serde(default = "grep_absent")]
49 pub grep: GrepConfig,
50 #[serde(default)]
53 pub maintenance: MaintenanceMode,
54 #[serde(default = "default_min_publish_interval_ms")]
60 pub min_publish_interval_ms: u64,
61 #[serde(default = "default_max_upload_bytes")]
69 pub max_upload_bytes: u64,
70 #[serde(default = "default_max_download_bytes")]
76 pub max_download_bytes: u64,
77 #[serde(default = "default_max_concurrent_uploads")]
82 pub max_concurrent_uploads: usize,
83 #[serde(default = "default_max_concurrent_downloads")]
87 pub max_concurrent_downloads: usize,
88 #[serde(default = "default_max_concurrent_maintenance")]
94 pub max_concurrent_maintenance: usize,
95 #[serde(default)]
100 pub allow_unauthenticated_remote: bool,
101 #[serde(default)]
108 pub allow_remote_without_tls: bool,
109 #[serde(default)]
113 pub tls: Option<TlsServerConfig>,
114 pub store: StoreConfig,
115}
116
117#[derive(Debug, Clone, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct TlsServerConfig {
123 pub cert_path: String,
125 pub key_path: String,
127}
128
129fn default_min_publish_interval_ms() -> u64 {
130 1_000
131}
132
133fn default_max_upload_bytes() -> u64 {
134 256 * 1024 * 1024
135}
136
137fn default_max_download_bytes() -> u64 {
138 256 * 1024 * 1024
142}
143
144fn default_max_concurrent_uploads() -> usize {
145 8
146}
147
148fn default_max_concurrent_downloads() -> usize {
149 16
150}
151
152fn default_max_concurrent_maintenance() -> usize {
153 loonfs::DEFAULT_MAX_CONCURRENT_MAINTENANCE
154}
155
156fn grep_absent() -> GrepConfig {
160 GrepConfig {
161 mode: GrepMode::Disabled,
162 ..GrepConfig::default()
163 }
164}
165
166#[derive(Debug, Clone, Default, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct RuntimeCacheConfigOverrides {
169 pub max_cached_namespaces: Option<usize>,
170 pub max_cached_wal_tail_projection_rows: Option<usize>,
171 pub max_cached_wal_tail_projection_decoded_bytes: Option<usize>,
172 pub metadata_table_cache_max_decoded_bytes: Option<usize>,
173}
174
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum MaintenanceMode {
193 #[default]
196 Automatic,
197 Manual,
200}
201
202impl MaintenanceMode {
203 pub fn registers_automatic_jobs(self) -> bool {
205 matches!(self, Self::Automatic)
206 }
207
208 pub fn background_work(self) -> loonfs::FsBackgroundWork {
210 match self {
211 Self::Automatic => loonfs::FsBackgroundWork::Enabled,
212 Self::Manual => loonfs::FsBackgroundWork::ManualOnly,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum GrepMode {
228 Disabled,
230 ServeOnly,
232 MaintainOnly,
234 #[default]
236 ServeAndMaintain,
237}
238
239impl GrepMode {
240 pub fn serves_grep(self) -> bool {
242 matches!(self, Self::ServeOnly | Self::ServeAndMaintain)
243 }
244
245 pub fn maintains_index(self) -> bool {
248 matches!(self, Self::MaintainOnly | Self::ServeAndMaintain)
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
254#[serde(default, deny_unknown_fields)]
255pub struct GrepConfig {
256 pub mode: GrepMode,
257 pub max_files_per_step: usize,
258 pub max_content_bytes_per_step: u64,
259 pub max_rows_per_segment: usize,
260 pub max_l0_runs: usize,
261 pub max_mid_runs: usize,
262 pub max_decoded_input_rows_per_step: usize,
263}
264
265impl GrepConfig {
266 pub fn worker_config(self) -> GrepWorkerConfig {
268 GrepWorkerConfig {
269 max_files_per_step: self.max_files_per_step,
270 max_content_bytes_per_step: self.max_content_bytes_per_step,
271 max_rows_per_segment: self.max_rows_per_segment,
272 max_l0_runs: self.max_l0_runs,
273 max_mid_runs: self.max_mid_runs,
274 max_decoded_input_rows_per_step: self.max_decoded_input_rows_per_step,
275 }
276 }
277}
278
279impl Default for GrepConfig {
280 fn default() -> Self {
281 let worker = GrepWorkerConfig::default();
282 Self {
283 mode: GrepMode::default(),
284 max_files_per_step: worker.max_files_per_step,
285 max_content_bytes_per_step: worker.max_content_bytes_per_step,
286 max_rows_per_segment: worker.max_rows_per_segment,
287 max_l0_runs: worker.max_l0_runs,
288 max_mid_runs: worker.max_mid_runs,
289 max_decoded_input_rows_per_step: worker.max_decoded_input_rows_per_step,
290 }
291 }
292}
293
294#[derive(Debug, Error)]
295pub enum ServerConfigError {
296 #[error("failed to read config: {0}")]
297 Io(String),
298 #[error("failed to decode config: {0}")]
299 Decode(String),
300 #[error("missing `{field}`")]
301 MissingField { field: &'static str },
302 #[error("missing `{field}`; set it in the config or export `{env}`")]
305 MissingCredential {
306 field: &'static str,
307 env: &'static str,
308 },
309 #[error("invalid `{field}`: {reason}")]
310 InvalidField { field: &'static str, reason: String },
311}
312
313impl ServerConfig {
314 pub(crate) fn content_token_secret(&self) -> &str {
315 self.content_token_secret.expose()
316 }
317
318 fn apply_env_fallbacks(
326 &mut self,
327 auth_token_env: Option<String>,
328 content_token_secret_env: Option<String>,
329 ) {
330 if self.auth_token.is_none() {
331 if let Some(token) = non_blank(auth_token_env) {
332 self.auth_token = Some(SecretString::new(token));
333 }
334 }
335 if self.content_token_secret.expose().trim().is_empty() {
336 if let Some(secret) = non_blank(content_token_secret_env) {
337 self.content_token_secret = SecretString::new(secret);
338 }
339 }
340 }
341
342 pub fn runtime_cache_config(&self) -> RuntimeCacheConfig {
343 let mut config = RuntimeCacheConfig::default();
344 if let Some(value) = self.runtime_cache.max_cached_namespaces {
345 config.max_cached_namespaces = value;
346 }
347 if let Some(value) = self.runtime_cache.max_cached_wal_tail_projection_rows {
348 config.max_cached_wal_tail_projection_rows = value;
349 }
350 if let Some(value) = self
351 .runtime_cache
352 .max_cached_wal_tail_projection_decoded_bytes
353 {
354 config.max_cached_wal_tail_projection_decoded_bytes = value;
355 }
356 if let Some(value) = self.runtime_cache.metadata_table_cache_max_decoded_bytes {
357 config.metadata_table_cache.max_decoded_bytes = value;
358 }
359 config
360 }
361
362 pub fn object_store(&self) -> Result<ConfiguredObjectStore, ServerConfigError> {
363 self.store
364 .configured_object_store()
365 .map_err(|err| ServerConfigError::InvalidField {
366 field: "store.key_prefix",
367 reason: err.to_string(),
368 })
369 }
370
371 pub(crate) fn bind_addr(&self) -> Result<SocketAddr, ServerConfigError> {
374 validate_socket_addr("bind", &self.bind)
375 }
376
377 pub(crate) fn validate(&self) -> Result<(), ServerConfigError> {
378 let bind = self.bind_addr()?;
379 require_non_empty("writer_id", &self.writer_id)?;
380
381 if let Some(token) = &self.auth_token {
382 if token.expose().trim().is_empty() {
383 return Err(ServerConfigError::InvalidField {
384 field: "auth_token",
385 reason: "must not be empty".to_owned(),
386 });
387 }
388 } else if bind_serves_beyond_localhost(&bind) && !self.allow_unauthenticated_remote {
389 return Err(ServerConfigError::InvalidField {
390 field: "auth_token",
391 reason: format!(
392 "bind `{bind}` serves every endpoint to the network without \
393 authentication; set `auth_token` (or `LOONFS_AUTH_TOKEN`), \
394 or set `allow_unauthenticated_remote = true` to serve open \
395 on purpose"
396 ),
397 });
398 }
399 if let Some(tls) = &self.tls {
400 require_non_empty("tls.cert_path", &tls.cert_path)?;
401 require_non_empty("tls.key_path", &tls.key_path)?;
402 } else if bind_serves_beyond_localhost(&bind) && !self.allow_remote_without_tls {
403 return Err(ServerConfigError::InvalidField {
404 field: "tls",
405 reason: format!(
406 "bind `{bind}` serves the network in plaintext, exposing the \
407 bearer token and the presigned object-store URLs in upload \
408 responses; configure `[tls]` with `cert_path` and `key_path`, \
409 or set `allow_remote_without_tls = true` when TLS terminates \
410 in front of this process"
411 ),
412 });
413 }
414 if self.max_upload_bytes == 0 {
415 return Err(ServerConfigError::InvalidField {
416 field: "max_upload_bytes",
417 reason: "must be greater than zero".to_owned(),
418 });
419 }
420 if self.max_download_bytes == 0 {
421 return Err(ServerConfigError::InvalidField {
422 field: "max_download_bytes",
423 reason: "must be greater than zero".to_owned(),
424 });
425 }
426 if self.max_concurrent_uploads == 0 {
427 return Err(ServerConfigError::InvalidField {
428 field: "max_concurrent_uploads",
429 reason: "must be greater than zero".to_owned(),
430 });
431 }
432 if self.max_concurrent_downloads == 0 {
433 return Err(ServerConfigError::InvalidField {
434 field: "max_concurrent_downloads",
435 reason: "must be greater than zero".to_owned(),
436 });
437 }
438 if self.max_concurrent_maintenance == 0 {
439 return Err(ServerConfigError::InvalidField {
440 field: "max_concurrent_maintenance",
441 reason: "must be greater than zero; \
442 set `maintenance = \"manual\"` to disable scheduling"
443 .to_owned(),
444 });
445 }
446 if let Err(error) = self.grep.worker_config().validate() {
447 return Err(ServerConfigError::InvalidField {
448 field: "grep",
449 reason: error.to_string(),
450 });
451 }
452 require_non_empty("content_token_secret", self.content_token_secret.expose())?;
453 self.store.validate().map_err(ServerConfigError::from)?;
454
455 Ok(())
456 }
457}
458
459impl From<StoreConfigError> for ServerConfigError {
460 fn from(error: StoreConfigError) -> Self {
461 match error {
462 StoreConfigError::MissingField { field } => ServerConfigError::MissingField { field },
463 StoreConfigError::MissingCredential { field, env } => {
464 ServerConfigError::MissingCredential { field, env }
465 }
466 StoreConfigError::InvalidField { field, reason } => {
467 ServerConfigError::InvalidField { field, reason }
468 }
469 }
470 }
471}
472
473pub fn load_server_config(path: impl AsRef<Path>) -> Result<ServerConfig, ServerConfigError> {
474 let bytes = fs::read(path.as_ref()).map_err(|err| ServerConfigError::Io(err.to_string()))?;
475 let source =
476 std::str::from_utf8(&bytes).map_err(|err| ServerConfigError::Decode(err.to_string()))?;
477 let mut config: ServerConfig =
478 toml::from_str(source).map_err(|err| ServerConfigError::Decode(err.to_string()))?;
479 config.apply_env_fallbacks(
480 env::var(AUTH_TOKEN_ENV).ok(),
481 env::var(CONTENT_TOKEN_SECRET_ENV).ok(),
482 );
483 config.store.apply_env_credentials();
484 config.validate()?;
485 config.object_store()?;
486 Ok(config)
487}
488
489fn non_blank(value: Option<String>) -> Option<String> {
490 value.filter(|value| !value.trim().is_empty())
491}
492
493fn require_non_empty(field: &'static str, value: &str) -> Result<(), ServerConfigError> {
494 if value.trim().is_empty() {
495 Err(ServerConfigError::MissingField { field })
496 } else {
497 Ok(())
498 }
499}
500
501fn validate_socket_addr(field: &'static str, value: &str) -> Result<SocketAddr, ServerConfigError> {
502 let trimmed = value.trim();
503 if trimmed.is_empty() {
504 return Err(ServerConfigError::MissingField { field });
505 }
506 trimmed
507 .parse::<SocketAddr>()
508 .map_err(|err| ServerConfigError::InvalidField {
509 field,
510 reason: err.to_string(),
511 })
512}
513
514fn bind_serves_beyond_localhost(addr: &SocketAddr) -> bool {
518 !addr.ip().is_loopback()
519}
520
521#[cfg(test)]
522mod tests {
523 #![allow(clippy::panic)]
524 use super::{load_server_config, ServerConfigError};
527 use std::fs;
528 use tempfile::tempdir;
529
530 const AZURITE_ACCOUNT_KEY: &str =
531 "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
532
533 #[test]
534 fn maintenance_defaults_to_automatic_and_accepts_manual() {
535 let path = write_config(
536 r#"
537bind = "127.0.0.1:9400"
538auth_token = "dev-token"
539writer_id = "loonfs-server"
540
541[store]
542kind = "local-fs"
543root = "/tmp/loonfs-server"
544"#,
545 );
546 let config = load_server_config(&path).expect("valid config");
547 assert_eq!(config.maintenance, super::MaintenanceMode::Automatic);
548 assert!(config.maintenance.registers_automatic_jobs());
549 assert_eq!(
550 config.maintenance.background_work(),
551 loonfs::FsBackgroundWork::Enabled
552 );
553
554 let path = write_config(
555 r#"
556bind = "127.0.0.1:9400"
557auth_token = "dev-token"
558writer_id = "loonfs-server"
559maintenance = "manual"
560
561[store]
562kind = "local-fs"
563root = "/tmp/loonfs-server"
564"#,
565 );
566 let config = load_server_config(&path).expect("valid config");
567 assert_eq!(
568 config.maintenance,
569 super::MaintenanceMode::Manual,
570 "write-serving nodes can hand maintenance to a dedicated process"
571 );
572 assert!(!config.maintenance.registers_automatic_jobs());
573 assert_eq!(
574 config.maintenance.background_work(),
575 loonfs::FsBackgroundWork::ManualOnly
576 );
577 }
578
579 #[test]
580 fn the_retired_background_maintenance_key_is_no_longer_a_key() {
581 let path = write_config(
585 r#"
586bind = "127.0.0.1:9400"
587auth_token = "dev-token"
588writer_id = "loonfs-server"
589background_maintenance = false
590
591[store]
592kind = "local-fs"
593root = "/tmp/loonfs-server"
594"#,
595 );
596
597 let error = load_server_config(&path).expect_err("retired key must not load");
598 assert!(
599 error.to_string().contains("background_maintenance"),
600 "{error}"
601 );
602 }
603
604 #[test]
605 fn an_unknown_maintenance_word_is_rejected() {
606 let path = write_config(
607 r#"
608bind = "127.0.0.1:9400"
609auth_token = "dev-token"
610writer_id = "loonfs-server"
611maintenance = "sometimes"
612
613[store]
614kind = "local-fs"
615root = "/tmp/loonfs-server"
616"#,
617 );
618
619 let error = load_server_config(&path).expect_err("unknown mode must not load");
620 match error {
621 ServerConfigError::Decode(message) => {
622 assert!(message.contains("automatic"), "{message}");
623 assert!(message.contains("manual"), "{message}");
624 }
625 other => panic!("expected decode error naming the modes, got {other:?}"),
626 }
627 }
628
629 #[test]
630 fn load_rejects_invalid_bind() {
631 let path = write_config(
632 r#"
633bind = "bad-bind"
634auth_token = "dev-token"
635writer_id = "loonfs-server"
636
637[store]
638kind = "local-fs"
639root = "/tmp/loonfs-server"
640"#,
641 );
642
643 let error = load_server_config(&path).expect_err("invalid bind");
644
645 assert_invalid_field(error, "bind");
646 }
647
648 #[test]
649 fn load_rejects_blank_writer_id() {
650 let path = write_config(
651 r#"
652bind = "127.0.0.1:9400"
653auth_token = "dev-token"
654writer_id = " "
655
656[store]
657kind = "local-fs"
658root = "/tmp/loonfs-server"
659"#,
660 );
661
662 let error = load_server_config(&path).expect_err("blank writer fields");
663
664 assert_missing_field(error, "writer_id");
665 }
666
667 #[test]
668 fn load_rejects_blank_provider_required_fields() {
669 let path = write_config(
670 r#"
671bind = "127.0.0.1:9400"
672auth_token = "dev-token"
673writer_id = "loonfs-server"
674
675[store]
676kind = "cloudflare-r2"
677bucket = " "
678account_id = "account"
679endpoint_url = "https://example.com"
680access_key_id = "access"
681secret_access_key = "secret"
682"#,
683 );
684
685 let error = load_server_config(&path).expect_err("blank bucket");
686
687 assert_missing_field(error, "store.bucket");
688 }
689
690 #[test]
691 fn load_rejects_invalid_endpoint_urls() {
692 let aws_path = write_config(
693 r#"
694bind = "127.0.0.1:9400"
695auth_token = "dev-token"
696writer_id = "loonfs-server"
697
698[store]
699kind = "aws-s3"
700bucket = "bucket"
701region = "us-east-1"
702endpoint_url = "ftp://example.com"
703access_key_id = "access"
704secret_access_key = "secret"
705key_prefix = "demo"
706force_path_style = false
707"#,
708 );
709 let r2_path = write_config(
710 r#"
711bind = "127.0.0.1:9400"
712auth_token = "dev-token"
713writer_id = "loonfs-server"
714
715[store]
716kind = "cloudflare-r2"
717bucket = "bucket"
718account_id = "account"
719endpoint_url = "not a url"
720access_key_id = "access"
721secret_access_key = "secret"
722key_prefix = "demo"
723"#,
724 );
725 let azure_path = write_config(&format!(
726 r#"
727bind = "127.0.0.1:9400"
728auth_token = "dev-token"
729writer_id = "loonfs-server"
730
731[store]
732kind = "azure-abs"
733account_name = "devstoreaccount1"
734container_name = "container"
735access_key = "{AZURITE_ACCOUNT_KEY}"
736endpoint_url = "not a url"
737key_prefix = "demo"
738"#
739 ));
740
741 let aws_error = load_server_config(&aws_path).expect_err("invalid aws endpoint");
742 let r2_error = load_server_config(&r2_path).expect_err("invalid r2 endpoint");
743 let azure_error = load_server_config(&azure_path).expect_err("invalid azure endpoint");
744
745 assert_invalid_field(aws_error, "store.endpoint_url");
746 assert_invalid_field(r2_error, "store.endpoint_url");
747 assert_invalid_field(azure_error, "store.endpoint_url");
748 }
749
750 #[test]
751 fn load_rejects_blank_gcs_bucket() {
752 let path = write_config(
753 r#"
754bind = "127.0.0.1:9400"
755auth_token = "dev-token"
756writer_id = "loonfs-server"
757
758[store]
759kind = "gcp-gcs"
760bucket = " "
761service_account_key_path = "/tmp/service-account.json"
762key_prefix = "demo"
763"#,
764 );
765
766 let error = load_server_config(&path).expect_err("blank gcs bucket");
767
768 assert_missing_field(error, "store.bucket");
769 }
770
771 #[test]
772 fn load_accepts_azure_abs_store() {
773 let path = write_config(&format!(
774 r#"
775bind = "127.0.0.1:9400"
776auth_token = "dev-token"
777writer_id = "loonfs-server"
778
779[store]
780kind = "azure-abs"
781account_name = "devstoreaccount1"
782container_name = "container"
783access_key = "{AZURITE_ACCOUNT_KEY}"
784endpoint_url = "http://127.0.0.1:10000/devstoreaccount1"
785key_prefix = "demo"
786"#
787 ));
788
789 load_server_config(&path).expect("load azure config");
790 }
791
792 #[test]
793 fn load_rejects_blank_azure_account_name() {
794 let path = write_config(&format!(
795 r#"
796bind = "127.0.0.1:9400"
797auth_token = "dev-token"
798writer_id = "loonfs-server"
799
800[store]
801kind = "azure-abs"
802account_name = " "
803container_name = "container"
804access_key = "{AZURITE_ACCOUNT_KEY}"
805"#
806 ));
807
808 let error = load_server_config(&path).expect_err("blank azure account name");
809
810 assert_missing_field(error, "store.account_name");
811 }
812
813 #[test]
814 fn load_rejects_blank_auth_token_when_present() {
815 let path = write_config(
816 r#"
817bind = "127.0.0.1:9400"
818auth_token = " "
819writer_id = "loonfs-server"
820
821[store]
822kind = "local-fs"
823root = "/tmp/loonfs-server"
824"#,
825 );
826
827 let error = load_server_config(&path).expect_err("blank auth token");
828
829 assert_invalid_field(error, "auth_token");
830 }
831
832 #[test]
833 fn load_rejects_non_loopback_bind_without_auth_token() {
834 if std::env::var("LOONFS_AUTH_TOKEN").is_ok() {
837 return;
838 }
839 for bind in ["0.0.0.0:9400", "[::]:9400", "10.1.2.3:9400"] {
840 let path = write_config(&format!(
841 r#"
842bind = "{bind}"
843writer_id = "loonfs-server"
844
845[store]
846kind = "local-fs"
847root = "/tmp/loonfs-server"
848"#
849 ));
850
851 let error = load_server_config(&path).expect_err("open network bind");
852
853 assert_invalid_field(error, "auth_token");
854 }
855 }
856
857 #[test]
858 fn allow_unauthenticated_remote_permits_an_open_bind() {
859 let path = write_config(
860 r#"
861bind = "0.0.0.0:9400"
862allow_unauthenticated_remote = true
863allow_remote_without_tls = true
864writer_id = "loonfs-server"
865
866[store]
867kind = "local-fs"
868root = "/tmp/loonfs-server"
869"#,
870 );
871
872 load_server_config(&path).expect("explicitly-open config loads");
873 }
874
875 #[test]
876 fn loopback_bind_without_auth_token_is_allowed() {
877 let path = write_config(
878 r#"
879bind = "127.0.0.1:9400"
880writer_id = "loonfs-server"
881
882[store]
883kind = "local-fs"
884root = "/tmp/loonfs-server"
885"#,
886 );
887
888 load_server_config(&path).expect("loopback-only config loads");
889 }
890
891 #[test]
892 fn load_rejects_non_loopback_bind_without_tls() {
893 for bind in ["0.0.0.0:9400", "[::]:9400", "10.1.2.3:9400"] {
894 let path = write_config(&format!(
895 r#"
896bind = "{bind}"
897auth_token = "dev-token"
898writer_id = "loonfs-server"
899
900[store]
901kind = "local-fs"
902root = "/tmp/loonfs-server"
903"#
904 ));
905
906 let error = load_server_config(&path).expect_err("plaintext network bind");
907
908 assert_invalid_field(error, "tls");
909 }
910 }
911
912 #[test]
913 fn allow_remote_without_tls_permits_a_plaintext_network_bind() {
914 let path = write_config(
915 r#"
916bind = "0.0.0.0:9400"
917auth_token = "dev-token"
918allow_remote_without_tls = true
919writer_id = "loonfs-server"
920
921[store]
922kind = "local-fs"
923root = "/tmp/loonfs-server"
924"#,
925 );
926
927 load_server_config(&path).expect("proxy-terminated config loads");
928 }
929
930 #[test]
931 fn tls_satisfies_the_network_bind_requirement() {
932 let path = write_config(
933 r#"
934bind = "0.0.0.0:9400"
935auth_token = "dev-token"
936writer_id = "loonfs-server"
937
938[tls]
939cert_path = "/etc/loonfs/tls/server.crt"
940key_path = "/etc/loonfs/tls/server.key"
941
942[store]
943kind = "local-fs"
944root = "/tmp/loonfs-server"
945"#,
946 );
947
948 let config = load_server_config(&path).expect("tls-terminating config loads");
949
950 let tls = config.tls.expect("tls table decodes");
951 assert_eq!(tls.cert_path, "/etc/loonfs/tls/server.crt");
952 assert_eq!(tls.key_path, "/etc/loonfs/tls/server.key");
953 }
954
955 #[test]
956 fn loopback_bind_accepts_tls() {
957 let path = write_config(
958 r#"
959bind = "127.0.0.1:9400"
960writer_id = "loonfs-server"
961
962[tls]
963cert_path = "/etc/loonfs/tls/server.crt"
964key_path = "/etc/loonfs/tls/server.key"
965
966[store]
967kind = "local-fs"
968root = "/tmp/loonfs-server"
969"#,
970 );
971
972 load_server_config(&path).expect("loopback tls config loads");
973 }
974
975 #[test]
976 fn load_rejects_blank_tls_paths() {
977 for (cert_path, key_path, field) in [
978 (" ", "/etc/loonfs/tls/server.key", "tls.cert_path"),
979 ("/etc/loonfs/tls/server.crt", "", "tls.key_path"),
980 ] {
981 let path = write_config(&format!(
982 r#"
983bind = "127.0.0.1:9400"
984writer_id = "loonfs-server"
985
986[tls]
987cert_path = "{cert_path}"
988key_path = "{key_path}"
989
990[store]
991kind = "local-fs"
992root = "/tmp/loonfs-server"
993"#
994 ));
995
996 let error = load_server_config(&path).expect_err("blank tls path");
997
998 assert_missing_field(error, field);
999 }
1000 }
1001
1002 #[test]
1003 fn load_rejects_unknown_tls_keys() {
1004 let path = write_config(
1005 r#"
1006bind = "127.0.0.1:9400"
1007writer_id = "loonfs-server"
1008
1009[tls]
1010cert_path = "/etc/loonfs/tls/server.crt"
1011key_path = "/etc/loonfs/tls/server.key"
1012client_ca_path = "/etc/loonfs/tls/clients.crt"
1013
1014[store]
1015kind = "local-fs"
1016root = "/tmp/loonfs-server"
1017"#,
1018 );
1019
1020 match load_server_config(&path).expect_err("unknown tls key") {
1021 ServerConfigError::Decode(message) => assert!(
1022 message.contains("client_ca_path"),
1023 "decode error must name the unknown key, got: {message}"
1024 ),
1025 other => panic!("expected a decode error, got {other:?}"),
1026 }
1027 }
1028
1029 #[test]
1030 fn max_upload_bytes_defaults_and_rejects_zero() {
1031 let path = write_config(
1032 r#"
1033bind = "127.0.0.1:9400"
1034auth_token = "dev-token"
1035writer_id = "loonfs-server"
1036
1037[store]
1038kind = "local-fs"
1039root = "/tmp/loonfs-server"
1040"#,
1041 );
1042 let config = load_server_config(&path).expect("valid config");
1043 assert_eq!(config.max_upload_bytes, 256 * 1024 * 1024);
1044 assert!(!config.allow_unauthenticated_remote);
1045
1046 let path = write_config(
1047 r#"
1048bind = "127.0.0.1:9400"
1049auth_token = "dev-token"
1050writer_id = "loonfs-server"
1051max_upload_bytes = 0
1052
1053[store]
1054kind = "local-fs"
1055root = "/tmp/loonfs-server"
1056"#,
1057 );
1058 let error = load_server_config(&path).expect_err("zero upload limit");
1059 assert_invalid_field(error, "max_upload_bytes");
1060 }
1061
1062 #[test]
1063 fn transfer_bounds_default_and_reject_zero() {
1064 let path = write_config(
1065 r#"
1066bind = "127.0.0.1:9400"
1067auth_token = "dev-token"
1068writer_id = "loonfs-server"
1069
1070[store]
1071kind = "local-fs"
1072root = "/tmp/loonfs-server"
1073"#,
1074 );
1075 let config = load_server_config(&path).expect("valid config");
1076 assert_eq!(config.max_download_bytes, 256 * 1024 * 1024);
1077 assert_eq!(config.max_concurrent_uploads, 8);
1078 assert_eq!(config.max_concurrent_downloads, 16);
1079 assert_eq!(
1080 config.max_concurrent_maintenance,
1081 loonfs::DEFAULT_MAX_CONCURRENT_MAINTENANCE
1082 );
1083
1084 for field in [
1085 "max_download_bytes",
1086 "max_concurrent_uploads",
1087 "max_concurrent_downloads",
1088 "max_concurrent_maintenance",
1089 ] {
1090 let path = write_config(&format!(
1091 r#"
1092bind = "127.0.0.1:9400"
1093auth_token = "dev-token"
1094writer_id = "loonfs-server"
1095{field} = 0
1096
1097[store]
1098kind = "local-fs"
1099root = "/tmp/loonfs-server"
1100"#
1101 ));
1102 let error = load_server_config(&path).expect_err("zero bound must be rejected");
1103 assert_invalid_field(error, field);
1104 }
1105
1106 let path = write_config(
1107 r#"
1108bind = "127.0.0.1:9400"
1109auth_token = "dev-token"
1110writer_id = "loonfs-server"
1111
1112[grep]
1113max_decoded_input_rows_per_step = 0
1114
1115[store]
1116kind = "local-fs"
1117root = "/tmp/loonfs-server"
1118"#,
1119 );
1120 let error = load_server_config(&path).expect_err("zero grep bound must be rejected");
1121 assert_invalid_field(error, "grep");
1122 }
1123
1124 #[test]
1125 fn server_config_debug_redacts_secrets() {
1126 let path = write_config(
1127 r#"
1128bind = "127.0.0.1:9400"
1129auth_token = "debug-auth-token"
1130content_token_secret = "debug-content-token-secret"
1131writer_id = "loonfs-server"
1132
1133[store]
1134kind = "aws-s3"
1135bucket = "bucket"
1136region = "us-east-1"
1137access_key_id = "debug-access-key-id"
1138secret_access_key = "debug-secret-access-key"
1139session_token = "debug-session-token"
1140key_prefix = "demo"
1141force_path_style = false
1142"#,
1143 );
1144 let config = load_server_config(&path).expect("load config");
1145
1146 let rendered = format!("{config:?}");
1147
1148 assert!(!rendered.contains("debug-auth-token"));
1149 assert!(!rendered.contains("debug-content-token-secret"));
1150 assert!(!rendered.contains("debug-access-key-id"));
1151 assert!(!rendered.contains("debug-secret-access-key"));
1152 assert!(!rendered.contains("debug-session-token"));
1153 }
1154
1155 #[test]
1156 fn env_fallbacks_fill_only_unset_secrets() {
1157 let path = write_config(
1158 r#"
1159bind = "127.0.0.1:9400"
1160auth_token = "file-auth-token"
1161writer_id = "loonfs-server"
1162
1163[store]
1164kind = "local-fs"
1165root = "/tmp/loonfs-server"
1166"#,
1167 );
1168 let mut config = load_server_config(&path).expect("load config");
1169
1170 config.apply_env_fallbacks(
1172 Some("env-auth-token".to_owned()),
1173 Some("env-content-token-secret".to_owned()),
1174 );
1175 assert_eq!(
1176 config.auth_token.as_ref().map(|token| token.expose()),
1177 Some("file-auth-token")
1178 );
1179 assert_eq!(config.content_token_secret(), "dev-content-token-secret");
1180
1181 config.auth_token = None;
1183 config.content_token_secret = loonfs_objectstore::SecretString::default();
1184 config.apply_env_fallbacks(
1185 Some("env-auth-token".to_owned()),
1186 Some("env-content-token-secret".to_owned()),
1187 );
1188 assert_eq!(
1189 config.auth_token.as_ref().map(|token| token.expose()),
1190 Some("env-auth-token")
1191 );
1192 assert_eq!(config.content_token_secret(), "env-content-token-secret");
1193
1194 config.auth_token = None;
1196 config.content_token_secret = loonfs_objectstore::SecretString::default();
1197 config.apply_env_fallbacks(Some(" ".to_owned()), Some(String::new()));
1198 assert!(config.auth_token.is_none());
1199 assert!(config.content_token_secret().is_empty());
1200 }
1201
1202 #[test]
1203 fn a_store_table_may_leave_its_credentials_to_the_environment() {
1204 let path = write_config(
1208 r#"
1209bind = "127.0.0.1:9400"
1210auth_token = "dev-token"
1211writer_id = "loonfs-server"
1212
1213[store]
1214kind = "aws-s3"
1215bucket = "bucket"
1216region = "us-east-1"
1217"#,
1218 );
1219 toml::from_str::<super::ServerConfig>(&fs::read_to_string(&path).expect("read config"))
1220 .expect("a store table without credentials parses");
1221
1222 if std::env::var("AWS_ACCESS_KEY_ID").is_err() {
1226 let error = load_server_config(&path).expect_err("no credentials anywhere");
1227 match error {
1228 ServerConfigError::MissingCredential { field, env } => {
1229 assert_eq!(field, "store.access_key_id");
1230 assert_eq!(env, "AWS_ACCESS_KEY_ID");
1231 }
1232 other => panic!("expected a missing-credential error, got {other:?}"),
1233 }
1234 }
1235 }
1236
1237 #[test]
1238 fn store_credentials_in_the_file_win_over_the_environment() {
1239 let path = write_config(
1240 r#"
1241bind = "127.0.0.1:9400"
1242auth_token = "dev-token"
1243writer_id = "loonfs-server"
1244
1245[store]
1246kind = "aws-s3"
1247bucket = "bucket"
1248region = "us-east-1"
1249access_key_id = "file-access"
1250secret_access_key = "file-secret"
1251"#,
1252 );
1253
1254 let config = load_server_config(&path).expect("load config");
1255 match config.store {
1256 super::StoreConfig::AwsS3 {
1257 access_key_id,
1258 secret_access_key,
1259 ..
1260 } => {
1261 assert_eq!(access_key_id.expose(), "file-access");
1262 assert_eq!(secret_access_key.expose(), "file-secret");
1263 }
1264 other => panic!("expected an aws-s3 store, got {other:?}"),
1265 }
1266 }
1267
1268 #[test]
1269 fn load_rejects_unknown_keys_at_every_level() {
1270 let top_level = write_config(
1271 r#"
1272bind = "127.0.0.1:9400"
1273auth_token = "dev-token"
1274writer_id = "loonfs-server"
1275lease_duration = 60000
1276
1277[store]
1278kind = "local-fs"
1279root = "/tmp/loonfs-server"
1280"#,
1281 );
1282 let store_level = write_config(
1283 r#"
1284bind = "127.0.0.1:9400"
1285auth_token = "dev-token"
1286writer_id = "loonfs-server"
1287
1288[store]
1289kind = "local-fs"
1290root = "/tmp/loonfs-server"
1291key_prefiks = "typo"
1292"#,
1293 );
1294 let runtime_cache_level = write_config(
1295 r#"
1296bind = "127.0.0.1:9400"
1297auth_token = "dev-token"
1298writer_id = "loonfs-server"
1299
1300[runtime_cache]
1301max_cached_namespacs = 2
1302
1303[store]
1304kind = "local-fs"
1305root = "/tmp/loonfs-server"
1306"#,
1307 );
1308 let grep_level = write_config(
1309 r#"
1310bind = "127.0.0.1:9400"
1311auth_token = "dev-token"
1312writer_id = "loonfs-server"
1313
1314[grep]
1315max_files_per_stepp = 3
1316
1317[store]
1318kind = "local-fs"
1319root = "/tmp/loonfs-server"
1320"#,
1321 );
1322
1323 for (path, typo) in [
1324 (top_level, "lease_duration"),
1325 (store_level, "key_prefiks"),
1326 (runtime_cache_level, "max_cached_namespacs"),
1327 (grep_level, "max_files_per_stepp"),
1328 ] {
1329 let error = load_server_config(&path).expect_err("typo'd key must be rejected");
1330 match error {
1331 ServerConfigError::Decode(message) => {
1332 assert!(
1333 message.contains(typo),
1334 "decode error must name `{typo}`, got: {message}"
1335 );
1336 }
1337 other => panic!("expected decode error naming {typo}, got {other:?}"),
1338 }
1339 }
1340 }
1341
1342 #[test]
1343 fn load_accepts_config_without_content_token_secret_field() {
1344 let path = write_config_verbatim(
1347 r#"
1348bind = "127.0.0.1:9400"
1349auth_token = "dev-token"
1350writer_id = "loonfs-server"
1351
1352[store]
1353kind = "local-fs"
1354root = "/tmp/loonfs-server"
1355"#,
1356 );
1357
1358 let mut config: super::ServerConfig =
1359 toml::from_str(&std::fs::read_to_string(&path).expect("read config"))
1360 .expect("config without content_token_secret parses");
1361 assert!(config.content_token_secret().is_empty());
1362
1363 config.apply_env_fallbacks(None, Some("env-content-token-secret".to_owned()));
1364 assert_eq!(config.content_token_secret(), "env-content-token-secret");
1365
1366 if std::env::var("LOONFS_CONTENT_TOKEN_SECRET").is_err() {
1368 let error = load_server_config(&path).expect_err("missing content token secret");
1369 assert_missing_field(error, "content_token_secret");
1370 }
1371 }
1372
1373 #[test]
1374 fn load_uses_default_runtime_cache_when_omitted() {
1375 let path = write_config(
1376 r#"
1377bind = "127.0.0.1:9400"
1378auth_token = "dev-token"
1379writer_id = "loonfs-server"
1380
1381[store]
1382kind = "local-fs"
1383root = "/tmp/loonfs-server"
1384"#,
1385 );
1386
1387 let config = load_server_config(&path).expect("load config");
1388 assert_eq!(
1389 config.runtime_cache_config(),
1390 loonfs::RuntimeCacheConfig::default()
1391 );
1392 }
1393
1394 #[test]
1395 fn load_applies_runtime_cache_overrides() {
1396 let path = write_config(
1397 r#"
1398bind = "127.0.0.1:9400"
1399auth_token = "dev-token"
1400writer_id = "loonfs-server"
1401
1402[runtime_cache]
1403max_cached_namespaces = 2
1404max_cached_wal_tail_projection_rows = 10
1405max_cached_wal_tail_projection_decoded_bytes = 4096
1406
1407[store]
1408kind = "local-fs"
1409root = "/tmp/loonfs-server"
1410"#,
1411 );
1412
1413 let config = load_server_config(&path)
1414 .expect("load config")
1415 .runtime_cache_config();
1416 assert_eq!(config.max_cached_namespaces, 2);
1417 assert_eq!(config.max_cached_wal_tail_projection_rows, 10);
1418 assert_eq!(config.max_cached_wal_tail_projection_decoded_bytes, 4096);
1419 }
1420
1421 #[test]
1422 fn load_accepts_disabled_runtime_cache_overrides() {
1423 let path = write_config(
1424 r#"
1425bind = "127.0.0.1:9400"
1426auth_token = "dev-token"
1427writer_id = "loonfs-server"
1428
1429[runtime_cache]
1430max_cached_namespaces = 0
1431max_cached_wal_tail_projection_rows = 0
1432max_cached_wal_tail_projection_decoded_bytes = 0
1433metadata_table_cache_max_decoded_bytes = 0
1434
1435[store]
1436kind = "local-fs"
1437root = "/tmp/loonfs-server"
1438"#,
1439 );
1440
1441 let config = load_server_config(&path)
1442 .expect("load config")
1443 .runtime_cache_config();
1444 assert_eq!(config, loonfs::RuntimeCacheConfig::disabled());
1445 }
1446
1447 #[test]
1448 fn an_omitted_grep_table_composes_no_grep() {
1449 let path = write_config(
1450 r#"
1451bind = "127.0.0.1:9400"
1452auth_token = "dev-token"
1453writer_id = "loonfs-server"
1454
1455[store]
1456kind = "local-fs"
1457root = "/tmp/loonfs-server"
1458"#,
1459 );
1460
1461 let config = load_server_config(&path).expect("load config");
1462 assert_eq!(config.grep.mode, super::GrepMode::Disabled);
1463 assert!(!config.grep.mode.serves_grep());
1464 assert!(!config.grep.mode.maintains_index());
1465 }
1466
1467 #[test]
1468 fn a_grep_table_without_a_mode_serves_and_maintains() {
1469 let path = write_config(
1470 r#"
1471bind = "127.0.0.1:9400"
1472auth_token = "dev-token"
1473writer_id = "loonfs-server"
1474
1475[grep]
1476
1477[store]
1478kind = "local-fs"
1479root = "/tmp/loonfs-server"
1480"#,
1481 );
1482
1483 let config = load_server_config(&path).expect("load config");
1484 assert_eq!(config.grep, super::GrepConfig::default());
1485 assert_eq!(config.grep.mode, super::GrepMode::ServeAndMaintain);
1486 assert_eq!(
1487 config
1488 .grep
1489 .worker_config()
1490 .build_policy()
1491 .expect("valid default grep policy"),
1492 loonfs_grep::GramIndexBuildPolicy::default(),
1493 );
1494 }
1495
1496 #[test]
1497 fn every_grep_mode_names_the_two_jobs_it_does() {
1498 for (spelling, mode, serves, maintains) in [
1499 ("disabled", super::GrepMode::Disabled, false, false),
1500 ("serve_only", super::GrepMode::ServeOnly, true, false),
1501 ("maintain_only", super::GrepMode::MaintainOnly, false, true),
1502 (
1503 "serve_and_maintain",
1504 super::GrepMode::ServeAndMaintain,
1505 true,
1506 true,
1507 ),
1508 ] {
1509 let path = write_config(&format!(
1510 r#"
1511bind = "127.0.0.1:9400"
1512auth_token = "dev-token"
1513writer_id = "loonfs-server"
1514
1515[grep]
1516mode = "{spelling}"
1517
1518[store]
1519kind = "local-fs"
1520root = "/tmp/loonfs-server"
1521"#
1522 ));
1523
1524 let config = load_server_config(&path).expect("load config");
1525 assert_eq!(config.grep.mode, mode);
1526 assert_eq!(config.grep.mode.serves_grep(), serves);
1527 assert_eq!(config.grep.mode.maintains_index(), maintains);
1528 }
1529 }
1530
1531 #[test]
1532 fn the_retired_step_concurrency_key_is_no_longer_a_key() {
1533 let path = write_config(
1536 r#"
1537bind = "127.0.0.1:9400"
1538auth_token = "dev-token"
1539writer_id = "loonfs-server"
1540
1541[grep]
1542max_concurrent_steps = 7
1543
1544[store]
1545kind = "local-fs"
1546root = "/tmp/loonfs-server"
1547"#,
1548 );
1549
1550 let error = load_server_config(&path).expect_err("retired key must not load");
1551 assert!(
1552 error.to_string().contains("max_concurrent_steps"),
1553 "{error}"
1554 );
1555 }
1556
1557 #[test]
1558 fn load_applies_grep_mode_and_policy() {
1559 let path = write_config(
1560 r#"
1561bind = "127.0.0.1:9400"
1562auth_token = "dev-token"
1563writer_id = "loonfs-server"
1564
1565[grep]
1566mode = "serve_only"
1567max_files_per_step = 4096
1568max_content_bytes_per_step = 536870912
1569max_rows_per_segment = 131072
1570max_l0_runs = 4
1571max_mid_runs = 6
1572max_decoded_input_rows_per_step = 262144
1573
1574[store]
1575kind = "local-fs"
1576root = "/tmp/loonfs-server"
1577"#,
1578 );
1579
1580 let grep = load_server_config(&path).expect("load config").grep;
1581 assert_eq!(grep.mode, super::GrepMode::ServeOnly);
1582 let policy = grep
1583 .worker_config()
1584 .build_policy()
1585 .expect("valid configured grep policy");
1586 assert_eq!(policy.max_files_per_step.get(), 4096);
1587 assert_eq!(policy.max_content_bytes_per_step.get(), 536_870_912);
1588 assert_eq!(policy.max_rows_per_segment.get(), 131_072);
1589 assert_eq!(policy.max_l0_runs.get(), 4);
1590 assert_eq!(policy.max_mid_runs.get(), 6);
1591 assert_eq!(policy.max_decoded_input_rows_per_step.get(), 262_144);
1592 }
1593
1594 #[test]
1595 fn grep_policy_overrides_apply_verbatim() {
1596 let path = write_config(
1599 r#"
1600bind = "127.0.0.1:9400"
1601auth_token = "dev-token"
1602writer_id = "loonfs-server"
1603
1604[grep]
1605max_files_per_step = 1024
1606max_l0_runs = 3
1607
1608[store]
1609kind = "local-fs"
1610root = "/tmp/loonfs-server"
1611"#,
1612 );
1613
1614 let policy = load_server_config(&path)
1615 .expect("load config")
1616 .grep
1617 .worker_config()
1618 .build_policy()
1619 .expect("valid configured grep policy");
1620 assert_eq!(policy.max_files_per_step.get(), 1024);
1621 assert_eq!(policy.max_l0_runs.get(), 3);
1622 assert_eq!(
1623 policy.max_mid_runs,
1624 loonfs_grep::GramIndexBuildPolicy::default().max_mid_runs,
1625 "untouched budgets keep their defaults"
1626 );
1627 }
1628
1629 #[test]
1630 fn unknown_config_tables_fail_decode() {
1631 let path = write_config(
1635 r#"
1636bind = "127.0.0.1:9400"
1637auth_token = "dev-token"
1638writer_id = "loonfs-server"
1639
1640[gram_index_build]
1641max_files_per_step = 4
1642
1643[store]
1644kind = "local-fs"
1645root = "/tmp/loonfs-server"
1646"#,
1647 );
1648
1649 let error = load_server_config(&path).expect_err("unknown table must fail");
1650 match error {
1651 ServerConfigError::Decode(message) => {
1652 assert!(message.contains("gram_index_build"), "{message}");
1653 }
1654 other => panic!("expected decode error, got {other:?}"),
1655 }
1656 }
1657
1658 #[test]
1659 fn load_rejects_negative_runtime_cache_limits_as_decode_error() {
1660 let path = write_config(
1661 r#"
1662bind = "127.0.0.1:9400"
1663auth_token = "dev-token"
1664writer_id = "loonfs-server"
1665
1666[runtime_cache]
1667max_cached_wal_tail_projection_rows = -1
1668
1669[store]
1670kind = "local-fs"
1671root = "/tmp/loonfs-server"
1672"#,
1673 );
1674
1675 let error = load_server_config(&path).expect_err("negative row limit");
1676 match error {
1677 ServerConfigError::Decode(_) => {}
1678 other => panic!("expected decode error, got {other:?}"),
1679 }
1680 }
1681
1682 #[test]
1685 fn server_example_configs_parse_and_validate() {
1686 let configs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs");
1687 let mut examples = 0usize;
1688 for entry in fs::read_dir(configs_dir).expect("read configs directory") {
1689 let path = entry.expect("read configs entry").path();
1690 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1691 continue;
1692 };
1693 if !name.starts_with("loonfs-server.") || !name.ends_with(".example.toml") {
1694 continue;
1695 }
1696 let contents = fs::read_to_string(&path).expect("read example config");
1697 let config: super::ServerConfig =
1698 toml::from_str(&contents).unwrap_or_else(|err| panic!("{name} must parse: {err}"));
1699 config
1700 .validate()
1701 .unwrap_or_else(|err| panic!("{name} must validate: {err}"));
1702 examples += 1;
1703 }
1704 assert!(
1705 examples >= 5,
1706 "expected at least 5 server example configs, found {examples}"
1707 );
1708 }
1709
1710 fn write_config(contents: &str) -> std::path::PathBuf {
1711 let contents = if contents.contains("content_token_secret") {
1712 contents.to_owned()
1713 } else {
1714 contents.replacen(
1715 "writer_id",
1716 "content_token_secret = \"dev-content-token-secret\"\nwriter_id",
1717 1,
1718 )
1719 };
1720 write_config_verbatim(&contents)
1721 }
1722
1723 fn write_config_verbatim(contents: &str) -> std::path::PathBuf {
1724 let temp_dir = tempdir().expect("tempdir");
1725 let path = temp_dir.path().join("server.toml");
1726 fs::write(&path, contents).expect("write config");
1727 let _ = temp_dir.keep();
1728 path
1729 }
1730
1731 fn assert_invalid_field(error: ServerConfigError, field: &'static str) {
1732 match error {
1733 ServerConfigError::InvalidField { field: actual, .. } => assert_eq!(actual, field),
1734 other => panic!("expected invalid field error for {field}, got {other:?}"),
1735 }
1736 }
1737
1738 fn assert_missing_field(error: ServerConfigError, field: &'static str) {
1739 match error {
1740 ServerConfigError::MissingField { field: actual } => assert_eq!(actual, field),
1741 other => panic!("expected missing field error for {field}, got {other:?}"),
1742 }
1743 }
1744}