Skip to main content

loonfs_server/
config.rs

1//! Server configuration: strict TOML decoding of the listen address,
2//! store, and runtime cache overrides.
3
4use 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
16/// Environment fallback for [`ServerConfig::auth_token`].
17const AUTH_TOKEN_ENV: &str = "LOONFS_AUTH_TOKEN";
18/// Environment fallback for [`ServerConfig::content_token_secret`].
19const CONTENT_TOKEN_SECRET_ENV: &str = "LOONFS_CONTENT_TOKEN_SECRET";
20
21/// The server config file.
22///
23/// # Secret precedence
24///
25/// `auth_token` and `content_token_secret` may be supplied through the
26/// `LOONFS_AUTH_TOKEN` and `LOONFS_CONTENT_TOKEN_SECRET` environment
27/// variables instead of the file, and the S3-compatible store credentials
28/// through the standard `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and
29/// `AWS_SESSION_TOKEN`. A non-blank value in the file always takes
30/// precedence; the environment variable fills the field only when the file
31/// leaves it unset (blank environment values are ignored).
32#[derive(Debug, Clone, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ServerConfig {
35    pub bind: String,
36    pub auth_token: Option<SecretString>,
37    /// Signs content tokens; unset or empty here falls back to
38    /// `LOONFS_CONTENT_TOKEN_SECRET`.
39    #[serde(default)]
40    pub content_token_secret: SecretString,
41    pub writer_id: String,
42    #[serde(default)]
43    pub runtime_cache: RuntimeCacheConfigOverrides,
44    /// What this server does about grep, plus the bounded-step budgets its
45    /// index maintenance runs under. A config with no `[grep]` table
46    /// composes no grep at all; a table that names no `mode` both serves
47    /// queries and maintains the index.
48    #[serde(default = "grep_absent")]
49    pub grep: GrepConfig,
50    /// Whether this server maintains the namespaces it touches by itself.
51    /// Automatic by default; see [`MaintenanceMode`].
52    #[serde(default)]
53    pub maintenance: MaintenanceMode,
54    /// Minimum interval between publication starts per namespace, in
55    /// milliseconds. A cold namespace publishes immediately; the interval
56    /// paces follow-up batches so hot namespaces amortize into fewer,
57    /// larger WAL segments. The server default favors batch economy over
58    /// the embedded default's latency bias.
59    #[serde(default = "default_min_publish_interval_ms")]
60    pub min_publish_interval_ms: u64,
61    /// Largest request body accepted for service-proxied upload content
62    /// requests (`PUT .../uploads/{upload_id}/content`). Enforced
63    /// incrementally while the body streams to the store, so it bounds the
64    /// accepted transfer size, not per-request memory (streamed writes hold
65    /// at most one internal part). Clients may use `direct_put` or direct
66    /// multipart for larger transfers when the capability is advertised.
67    /// Advertised as the `upload.max_content_bytes` capability limit.
68    #[serde(default = "default_max_upload_bytes")]
69    pub max_upload_bytes: u64,
70    /// Largest file content a service-proxied read (`GET .../filesystem/
71    /// content` and inode revision content) will buffer and return. Checked
72    /// against resolved metadata before any content fetch; over-limit reads
73    /// answer `content_too_large`. Advertised to clients as the
74    /// `download.max_content_bytes` capability limit.
75    #[serde(default = "default_max_download_bytes")]
76    pub max_download_bytes: u64,
77    /// How many proxied upload bodies the server will stream at once;
78    /// requests past the cap answer `server_busy` before any transfer.
79    /// Worst-case upload memory is this times one streamed part, since
80    /// bodies forward to the store incrementally instead of buffering.
81    #[serde(default = "default_max_concurrent_uploads")]
82    pub max_concurrent_uploads: usize,
83    /// How many proxied content reads the server will materialize at once;
84    /// requests past the cap answer `server_busy` before any fetch.
85    /// Worst-case download memory is this times `max_download_bytes`.
86    #[serde(default = "default_max_concurrent_downloads")]
87    pub max_concurrent_downloads: usize,
88    /// How many writer-scheduled maintenance steps may run at once across
89    /// every job and namespace. Each job runs at most one step per
90    /// namespace at a time; this bounds the fan-out when a write burst
91    /// crosses thresholds in many namespaces together. A step that waits
92    /// for a permit takes the next one that frees.
93    #[serde(default = "default_max_concurrent_maintenance")]
94    pub max_concurrent_maintenance: usize,
95    /// Allows serving on a non-loopback address with `auth_token` unset.
96    /// Off by default: exposing every endpoint unauthenticated is almost
97    /// always a misconfiguration, so validation rejects it unless this is
98    /// explicitly set.
99    #[serde(default)]
100    pub allow_unauthenticated_remote: bool,
101    /// Allows serving on a non-loopback address in plaintext. Off by
102    /// default for the same reason as `allow_unauthenticated_remote`: the
103    /// wire carries the bearer token and the presigned object-store URLs
104    /// the upload routes hand back, so plaintext beyond localhost is almost
105    /// always a misconfiguration rather than a choice. Set it where TLS
106    /// terminates in front of this process.
107    #[serde(default)]
108    pub allow_remote_without_tls: bool,
109    /// Terminates TLS in this process when present. Absent means plaintext
110    /// HTTP, which validation only accepts on a loopback bind or with
111    /// `allow_remote_without_tls`.
112    #[serde(default)]
113    pub tls: Option<TlsServerConfig>,
114    pub store: StoreConfig,
115}
116
117/// The server's TLS identity: one certificate chain and its private key,
118/// both read at startup. A file that is missing, unreadable, or not the PEM
119/// it claims to be fails the process rather than degrading to plaintext.
120#[derive(Debug, Clone, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct TlsServerConfig {
123    /// PEM certificate chain, leaf first.
124    pub cert_path: String,
125    /// PEM private key: PKCS#8, PKCS#1 (RSA), or SEC1.
126    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    // Mirrors the upload default so anything the proxy accepted, the proxy
139    // will serve back. Content ingested past this through `direct_put`
140    // needs a raised limit to be read through the server.
141    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
156/// What a config with no `[grep]` table asks for: nothing composed, no
157/// query plane advertised, no index job registered. Saying `[grep]` at all
158/// is what opts a deployment in.
159fn 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/// Whether this server maintains the namespaces it touches.
176///
177/// One word decides it, and it is the only switch: `automatic` registers the
178/// runtime's own jobs — metadata upkeep and collection — and the grep index
179/// job when `[grep]`'s mode maintains, and lets the writer's runner schedule
180/// all of them; `manual` registers nothing automatic and schedules nothing.
181/// Explicit admin operations work identically either way, and the retention
182/// floor is never advanced automatically under either.
183///
184/// Set `manual` on a write-serving node when a dedicated maintenance
185/// process — `loonfs admin run --namespace ...`, or another server — owns
186/// upkeep for these namespaces. Automatic maintenance covers namespaces
187/// touched by the running process and namespaces explicitly assigned to a
188/// maintenance host, so a deployment that switches this off has to assign
189/// its namespaces somewhere.
190#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum MaintenanceMode {
193    /// The writer schedules its own metadata and collection steps, and the
194    /// grep index job when the grep mode maintains.
195    #[default]
196    Automatic,
197    /// Nothing is scheduled and no automatic job is registered. Explicit
198    /// admin operations remain available.
199    Manual,
200}
201
202impl MaintenanceMode {
203    /// Whether this server registers automatic maintenance jobs at all.
204    pub fn registers_automatic_jobs(self) -> bool {
205        matches!(self, Self::Automatic)
206    }
207
208    /// The writer policy this mode selects.
209    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/// What this server does about grep: answer queries, keep the index built,
218/// both, or neither.
219///
220/// The two jobs are independent. A read replica can serve searches over an
221/// index another process maintains; a write node can maintain the index for
222/// namespaces it never answers searches about; the reference deployment
223/// does both. Every combination is named here, so none has to be validated
224/// away.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum GrepMode {
228    /// Neither answer grep queries nor maintain the index.
229    Disabled,
230    /// Answer queries over an index some other process maintains.
231    ServeOnly,
232    /// Maintain the index without answering queries about it.
233    MaintainOnly,
234    /// Answer queries and maintain the index in this process.
235    #[default]
236    ServeAndMaintain,
237}
238
239impl GrepMode {
240    /// Whether the grep query endpoint is supported.
241    pub fn serves_grep(self) -> bool {
242        matches!(self, Self::ServeOnly | Self::ServeAndMaintain)
243    }
244
245    /// Whether this server's writer registers the grep index job — which is
246    /// also what the index-administration endpoints act through.
247    pub fn maintains_index(self) -> bool {
248        matches!(self, Self::MaintainOnly | Self::ServeAndMaintain)
249    }
250}
251
252/// The server's `[grep]` table.
253#[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    /// Returns the shared bounded-step configuration represented by this table.
267    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    /// A store credential absent from the file and from the environment
303    /// variable that can stand in for it.
304    #[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    /// Fills `auth_token` and `content_token_secret` from the environment
319    /// when the file left them unset. Non-blank file values win; blank
320    /// environment values are ignored.
321    ///
322    /// The store's own credentials follow the same rule but read their own
323    /// standard variables, so [`StoreConfig::apply_env_credentials`] owns
324    /// that half and this function stays about the server's two secrets.
325    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    /// Parses the bind address; the one authority for that conversion, used
372    /// by validation and by serving.
373    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
514/// Whether a bind address accepts connections from other hosts: any
515/// non-loopback ip, including the unspecified addresses (`0.0.0.0`, `[::]`)
516/// that bind every interface.
517fn bind_serves_beyond_localhost(addr: &SocketAddr) -> bool {
518    !addr.ip().is_loopback()
519}
520
521#[cfg(test)]
522mod tests {
523    #![allow(clippy::panic)]
524    // Config tests use panic in unexpected match arms for precise diagnostics.
525
526    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        // One word decides automatic maintenance now, and `maintenance` is
582        // the word. The boolean it replaced fails through strict decoding
583        // like any other unknown key.
584        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        // LOONFS_AUTH_TOKEN in the environment would legitimately fill the
835        // token and make this config valid; only assert when it is unset.
836        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        // File values win over the environment.
1171        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        // The environment fills fields the file left unset.
1182        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        // Blank environment values are ignored.
1195        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        // A credential-less `[store]` used to fail decoding, before any
1205        // environment fallback could apply. It now parses, and loading
1206        // resolves the credentials from the standard variables.
1207        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        // Only assert the empty-environment answer when the environment is
1223        // in fact empty: a developer's own AWS credentials would legitimately
1224        // complete this config.
1225        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        // `content_token_secret` may come from LOONFS_CONTENT_TOKEN_SECRET
1345        // instead of the file; omitting both must still fail validation.
1346        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        // Without the env fallback the load path reports the missing field.
1367        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        // One permit pool bounds every maintenance family now, and it is
1534        // configured by `max_concurrent_maintenance`.
1535        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        // The policy handed to the worker is exactly the configured one:
1597        // zero budgets are rejected by validation, never rewritten.
1598        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        // Pre-release rule: no compatibility courtesies. An unrecognized
1632        // table — including any removed one — fails through the config's
1633        // own strict parsing, with no special-cased guidance.
1634        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    /// Every server example config must keep parsing into [`ServerConfig`]
1683    /// (including under `deny_unknown_fields`) and passing field validation.
1684    #[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}