1#![warn(missing_docs)]
2
3use std::collections::HashMap;
30use std::path::Path;
31
32use serde::Deserialize;
33use thiserror::Error;
34
35#[derive(Debug, Error)]
37pub enum ConfigError {
38 #[error("failed to read config file: {0}")]
40 Io(#[from] std::io::Error),
41
42 #[error("failed to parse config: {0}")]
44 Parse(#[from] toml::de::Error),
45
46 #[error("invalid config: {0}")]
48 Invalid(String),
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct ForgeConfig {
54 #[serde(default)]
56 pub servers: HashMap<String, ServerConfig>,
57
58 #[serde(default)]
60 pub sandbox: SandboxOverrides,
61
62 #[serde(default)]
64 pub groups: HashMap<String, GroupConfig>,
65}
66
67#[derive(Debug, Clone, Deserialize)]
69pub struct GroupConfig {
70 pub servers: Vec<String>,
72
73 #[serde(default = "default_isolation")]
75 pub isolation: String,
76}
77
78fn default_isolation() -> String {
79 "open".to_string()
80}
81
82#[derive(Debug, Clone, Deserialize)]
84pub struct ServerConfig {
85 pub transport: String,
87
88 #[serde(default)]
90 pub command: Option<String>,
91
92 #[serde(default)]
94 pub args: Vec<String>,
95
96 #[serde(default)]
98 pub url: Option<String>,
99
100 #[serde(default)]
102 pub headers: HashMap<String, String>,
103
104 #[serde(default)]
106 pub description: Option<String>,
107
108 #[serde(default)]
110 pub timeout_secs: Option<u64>,
111
112 #[serde(default)]
114 pub circuit_breaker: Option<bool>,
115
116 #[serde(default)]
118 pub failure_threshold: Option<u32>,
119
120 #[serde(default)]
122 pub recovery_timeout_secs: Option<u64>,
123}
124
125#[derive(Debug, Clone, Default, Deserialize)]
127pub struct SandboxOverrides {
128 #[serde(default)]
130 pub timeout_secs: Option<u64>,
131
132 #[serde(default)]
134 pub max_heap_mb: Option<usize>,
135
136 #[serde(default)]
138 pub max_concurrent: Option<usize>,
139
140 #[serde(default)]
142 pub max_tool_calls: Option<usize>,
143
144 #[serde(default)]
146 pub execution_mode: Option<String>,
147
148 #[serde(default)]
150 pub max_ipc_message_size_mb: Option<usize>,
151
152 #[serde(default)]
154 pub max_resource_size_mb: Option<usize>,
155
156 #[serde(default)]
158 pub max_parallel: Option<usize>,
159
160 #[serde(default)]
162 pub stash: Option<StashOverrides>,
163}
164
165#[derive(Debug, Clone, Default, Deserialize)]
167pub struct StashOverrides {
168 #[serde(default)]
170 pub max_keys: Option<usize>,
171
172 #[serde(default)]
174 pub max_value_size_mb: Option<usize>,
175
176 #[serde(default)]
178 pub max_total_size_mb: Option<usize>,
179
180 #[serde(default)]
182 pub default_ttl_secs: Option<u64>,
183
184 #[serde(default)]
186 pub max_ttl_secs: Option<u64>,
187}
188
189impl ForgeConfig {
190 pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
192 let config: ForgeConfig = toml::from_str(toml_str)?;
193 config.validate()?;
194 Ok(config)
195 }
196
197 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
199 let content = std::fs::read_to_string(path)?;
200 Self::from_toml(&content)
201 }
202
203 pub fn from_toml_with_env(toml_str: &str) -> Result<Self, ConfigError> {
205 let expanded = expand_env_vars(toml_str);
206 Self::from_toml(&expanded)
207 }
208
209 pub fn from_file_with_env(path: &Path) -> Result<Self, ConfigError> {
211 let content = std::fs::read_to_string(path)?;
212 Self::from_toml_with_env(&content)
213 }
214
215 fn validate(&self) -> Result<(), ConfigError> {
216 for (name, server) in &self.servers {
217 match server.transport.as_str() {
218 "stdio" => {
219 if server.command.is_none() {
220 return Err(ConfigError::Invalid(format!(
221 "server '{}': stdio transport requires 'command'",
222 name
223 )));
224 }
225 }
226 "sse" => {
227 if server.url.is_none() {
228 return Err(ConfigError::Invalid(format!(
229 "server '{}': sse transport requires 'url'",
230 name
231 )));
232 }
233 }
234 other => {
235 return Err(ConfigError::Invalid(format!(
236 "server '{}': unsupported transport '{}', supported: stdio, sse",
237 name, other
238 )));
239 }
240 }
241 }
242
243 let mut seen_servers: HashMap<&str, &str> = HashMap::new();
245 for (group_name, group_config) in &self.groups {
246 match group_config.isolation.as_str() {
248 "strict" | "open" => {}
249 other => {
250 return Err(ConfigError::Invalid(format!(
251 "group '{}': unsupported isolation '{}', supported: strict, open",
252 group_name, other
253 )));
254 }
255 }
256
257 for server_ref in &group_config.servers {
258 if !self.servers.contains_key(server_ref) {
260 return Err(ConfigError::Invalid(format!(
261 "group '{}': references unknown server '{}'",
262 group_name, server_ref
263 )));
264 }
265 if let Some(existing_group) = seen_servers.get(server_ref.as_str()) {
267 return Err(ConfigError::Invalid(format!(
268 "server '{}' is in multiple groups: '{}' and '{}'",
269 server_ref, existing_group, group_name
270 )));
271 }
272 seen_servers.insert(server_ref, group_name);
273 }
274 }
275
276 self.validate_sandbox_v2()?;
278
279 Ok(())
280 }
281
282 fn validate_sandbox_v2(&self) -> Result<(), ConfigError> {
283 if let Some(size) = self.sandbox.max_resource_size_mb {
285 if size == 0 || size > 512 {
286 return Err(ConfigError::Invalid(
287 "sandbox.max_resource_size_mb must be > 0 and <= 512".into(),
288 ));
289 }
290 }
291
292 if let Some(parallel) = self.sandbox.max_parallel {
294 let max_concurrent = self.sandbox.max_concurrent.unwrap_or(8);
295 if parallel < 1 || parallel > max_concurrent {
296 return Err(ConfigError::Invalid(format!(
297 "sandbox.max_parallel must be >= 1 and <= max_concurrent ({})",
298 max_concurrent
299 )));
300 }
301 }
302
303 if let Some(ref stash) = self.sandbox.stash {
304 if let Some(size) = stash.max_value_size_mb {
306 if size == 0 || size > 256 {
307 return Err(ConfigError::Invalid(
308 "sandbox.stash.max_value_size_mb must be > 0 and <= 256".into(),
309 ));
310 }
311 }
312
313 if let (Some(total), Some(value)) = (stash.max_total_size_mb, stash.max_value_size_mb) {
315 if total < value {
316 return Err(ConfigError::Invalid(
317 "sandbox.stash.max_total_size_mb must be >= sandbox.stash.max_value_size_mb"
318 .into(),
319 ));
320 }
321 }
322
323 if let Some(default_ttl) = stash.default_ttl_secs {
325 if default_ttl == 0 {
326 return Err(ConfigError::Invalid(
327 "sandbox.stash.default_ttl_secs must be > 0".into(),
328 ));
329 }
330 let max_ttl = stash.max_ttl_secs.unwrap_or(86400);
331 if default_ttl > max_ttl {
332 return Err(ConfigError::Invalid(format!(
333 "sandbox.stash.default_ttl_secs ({}) must be <= max_ttl_secs ({})",
334 default_ttl, max_ttl
335 )));
336 }
337 }
338
339 if let Some(max_ttl) = stash.max_ttl_secs {
341 if max_ttl == 0 || max_ttl > 604800 {
342 return Err(ConfigError::Invalid(
343 "sandbox.stash.max_ttl_secs must be > 0 and <= 604800 (7 days)".into(),
344 ));
345 }
346 }
347 }
348
349 if let Some(resource_mb) = self.sandbox.max_resource_size_mb {
352 let ipc_limit_mb = self.sandbox.max_ipc_message_size_mb.unwrap_or(8); if resource_mb + 1 > ipc_limit_mb {
354 return Err(ConfigError::Invalid(format!(
355 "sandbox.max_resource_size_mb ({}) + 1 MB overhead exceeds IPC message limit ({} MB)",
356 resource_mb, ipc_limit_mb
357 )));
358 }
359 }
360
361 Ok(())
362 }
363}
364
365fn expand_env_vars(input: &str) -> String {
367 let mut result = String::with_capacity(input.len());
368 let mut chars = input.chars().peekable();
369
370 while let Some(ch) = chars.next() {
371 if ch == '$' && chars.peek() == Some(&'{') {
372 chars.next(); let mut var_name = String::new();
374 for c in chars.by_ref() {
375 if c == '}' {
376 break;
377 }
378 var_name.push(c);
379 }
380 match std::env::var(&var_name) {
381 Ok(value) => result.push_str(&value),
382 Err(_) => {
383 result.push_str(&format!("${{{}}}", var_name));
385 }
386 }
387 } else {
388 result.push(ch);
389 }
390 }
391
392 result
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn config_parses_minimal_toml() {
401 let toml = r#"
402 [servers.narsil]
403 command = "narsil-mcp"
404 transport = "stdio"
405 "#;
406
407 let config = ForgeConfig::from_toml(toml).unwrap();
408 assert_eq!(config.servers.len(), 1);
409 let narsil = &config.servers["narsil"];
410 assert_eq!(narsil.transport, "stdio");
411 assert_eq!(narsil.command.as_deref(), Some("narsil-mcp"));
412 }
413
414 #[test]
415 fn config_parses_sse_server() {
416 let toml = r#"
417 [servers.github]
418 url = "https://mcp.github.com/sse"
419 transport = "sse"
420 "#;
421
422 let config = ForgeConfig::from_toml(toml).unwrap();
423 let github = &config.servers["github"];
424 assert_eq!(github.transport, "sse");
425 assert_eq!(github.url.as_deref(), Some("https://mcp.github.com/sse"));
426 }
427
428 #[test]
429 fn config_parses_sandbox_overrides() {
430 let toml = r#"
431 [sandbox]
432 timeout_secs = 10
433 max_heap_mb = 128
434 max_concurrent = 4
435 max_tool_calls = 100
436 "#;
437
438 let config = ForgeConfig::from_toml(toml).unwrap();
439 assert_eq!(config.sandbox.timeout_secs, Some(10));
440 assert_eq!(config.sandbox.max_heap_mb, Some(128));
441 assert_eq!(config.sandbox.max_concurrent, Some(4));
442 assert_eq!(config.sandbox.max_tool_calls, Some(100));
443 }
444
445 #[test]
446 fn config_expands_environment_variables() {
447 temp_env::with_var("FORGE_TEST_TOKEN", Some("secret123"), || {
448 let toml = r#"
449 [servers.github]
450 url = "https://mcp.github.com/sse"
451 transport = "sse"
452 headers = { Authorization = "Bearer ${FORGE_TEST_TOKEN}" }
453 "#;
454
455 let config = ForgeConfig::from_toml_with_env(toml).unwrap();
456 let github = &config.servers["github"];
457 assert_eq!(
458 github.headers.get("Authorization").unwrap(),
459 "Bearer secret123"
460 );
461 });
462 }
463
464 #[test]
465 fn config_rejects_invalid_transport() {
466 let toml = r#"
467 [servers.test]
468 command = "test"
469 transport = "grpc"
470 "#;
471
472 let err = ForgeConfig::from_toml(toml).unwrap_err();
473 let msg = err.to_string();
474 assert!(
475 msg.contains("grpc"),
476 "error should mention the transport: {msg}"
477 );
478 assert!(
479 msg.contains("stdio"),
480 "error should mention supported transports: {msg}"
481 );
482 }
483
484 #[test]
485 fn config_rejects_stdio_without_command() {
486 let toml = r#"
487 [servers.test]
488 transport = "stdio"
489 "#;
490
491 let err = ForgeConfig::from_toml(toml).unwrap_err();
492 assert!(err.to_string().contains("command"));
493 }
494
495 #[test]
496 fn config_rejects_sse_without_url() {
497 let toml = r#"
498 [servers.test]
499 transport = "sse"
500 "#;
501
502 let err = ForgeConfig::from_toml(toml).unwrap_err();
503 assert!(err.to_string().contains("url"));
504 }
505
506 #[test]
507 fn config_loads_from_file() {
508 let dir = std::env::temp_dir().join("forge-config-test");
509 std::fs::create_dir_all(&dir).unwrap();
510 let path = dir.join("forge.toml");
511 std::fs::write(
512 &path,
513 r#"
514 [servers.test]
515 command = "test-server"
516 transport = "stdio"
517 "#,
518 )
519 .unwrap();
520
521 let config = ForgeConfig::from_file(&path).unwrap();
522 assert_eq!(config.servers.len(), 1);
523 assert_eq!(
524 config.servers["test"].command.as_deref(),
525 Some("test-server")
526 );
527
528 std::fs::remove_dir_all(&dir).ok();
529 }
530
531 #[test]
532 fn config_uses_defaults_when_absent() {
533 let toml = r#"
534 [servers.test]
535 command = "test"
536 transport = "stdio"
537 "#;
538
539 let config = ForgeConfig::from_toml(toml).unwrap();
540 assert!(config.sandbox.timeout_secs.is_none());
541 assert!(config.sandbox.max_heap_mb.is_none());
542 assert!(config.sandbox.max_concurrent.is_none());
543 assert!(config.sandbox.max_tool_calls.is_none());
544 }
545
546 #[test]
547 fn config_parses_full_example() {
548 let toml = r#"
549 [servers.narsil]
550 command = "narsil-mcp"
551 args = ["--repos", ".", "--streaming"]
552 transport = "stdio"
553 description = "Code intelligence"
554
555 [servers.github]
556 url = "https://mcp.github.com/sse"
557 transport = "sse"
558 headers = { Authorization = "Bearer token123" }
559
560 [sandbox]
561 timeout_secs = 5
562 max_heap_mb = 64
563 max_concurrent = 8
564 max_tool_calls = 50
565 "#;
566
567 let config = ForgeConfig::from_toml(toml).unwrap();
568 assert_eq!(config.servers.len(), 2);
569
570 let narsil = &config.servers["narsil"];
571 assert_eq!(narsil.command.as_deref(), Some("narsil-mcp"));
572 assert_eq!(narsil.args, vec!["--repos", ".", "--streaming"]);
573 assert_eq!(narsil.description.as_deref(), Some("Code intelligence"));
574
575 let github = &config.servers["github"];
576 assert_eq!(github.url.as_deref(), Some("https://mcp.github.com/sse"));
577 assert_eq!(
578 github.headers.get("Authorization").unwrap(),
579 "Bearer token123"
580 );
581
582 assert_eq!(config.sandbox.timeout_secs, Some(5));
583 }
584
585 #[test]
586 fn config_empty_servers_is_valid() {
587 let toml = "";
588 let config = ForgeConfig::from_toml(toml).unwrap();
589 assert!(config.servers.is_empty());
590 }
591
592 #[test]
593 fn env_var_expansion_preserves_unresolved() {
594 let result = expand_env_vars("prefix ${DEFINITELY_NOT_SET_12345} suffix");
595 assert_eq!(result, "prefix ${DEFINITELY_NOT_SET_12345} suffix");
596 }
597
598 #[test]
599 fn env_var_expansion_handles_no_vars() {
600 let result = expand_env_vars("no variables here");
601 assert_eq!(result, "no variables here");
602 }
603
604 #[test]
605 fn config_parses_execution_mode_child_process() {
606 let toml = r#"
607 [sandbox]
608 execution_mode = "child_process"
609 "#;
610
611 let config = ForgeConfig::from_toml(toml).unwrap();
612 assert_eq!(
613 config.sandbox.execution_mode.as_deref(),
614 Some("child_process")
615 );
616 }
617
618 #[test]
619 fn config_parses_groups() {
620 let toml = r#"
621 [servers.vault]
622 command = "vault-mcp"
623 transport = "stdio"
624
625 [servers.slack]
626 command = "slack-mcp"
627 transport = "stdio"
628
629 [groups.internal]
630 servers = ["vault"]
631 isolation = "strict"
632
633 [groups.external]
634 servers = ["slack"]
635 isolation = "open"
636 "#;
637
638 let config = ForgeConfig::from_toml(toml).unwrap();
639 assert_eq!(config.groups.len(), 2);
640 assert_eq!(config.groups["internal"].isolation, "strict");
641 assert_eq!(config.groups["external"].servers, vec!["slack"]);
642 }
643
644 #[test]
645 fn config_groups_default_to_empty() {
646 let toml = r#"
647 [servers.test]
648 command = "test"
649 transport = "stdio"
650 "#;
651 let config = ForgeConfig::from_toml(toml).unwrap();
652 assert!(config.groups.is_empty());
653 }
654
655 #[test]
656 fn config_rejects_group_with_unknown_server() {
657 let toml = r#"
658 [servers.real]
659 command = "real"
660 transport = "stdio"
661
662 [groups.bad]
663 servers = ["nonexistent"]
664 "#;
665 let err = ForgeConfig::from_toml(toml).unwrap_err();
666 let msg = err.to_string();
667 assert!(msg.contains("nonexistent"), "should mention server: {msg}");
668 assert!(msg.contains("unknown"), "should say unknown: {msg}");
669 }
670
671 #[test]
672 fn config_rejects_server_in_multiple_groups() {
673 let toml = r#"
674 [servers.shared]
675 command = "shared"
676 transport = "stdio"
677
678 [groups.a]
679 servers = ["shared"]
680
681 [groups.b]
682 servers = ["shared"]
683 "#;
684 let err = ForgeConfig::from_toml(toml).unwrap_err();
685 let msg = err.to_string();
686 assert!(msg.contains("shared"), "should mention server: {msg}");
687 assert!(
688 msg.contains("multiple groups"),
689 "should say multiple groups: {msg}"
690 );
691 }
692
693 #[test]
694 fn config_rejects_invalid_isolation_mode() {
695 let toml = r#"
696 [servers.test]
697 command = "test"
698 transport = "stdio"
699
700 [groups.bad]
701 servers = ["test"]
702 isolation = "paranoid"
703 "#;
704 let err = ForgeConfig::from_toml(toml).unwrap_err();
705 let msg = err.to_string();
706 assert!(msg.contains("paranoid"), "should mention mode: {msg}");
707 }
708
709 #[test]
710 fn config_parses_server_timeout() {
711 let toml = r#"
712 [servers.slow]
713 command = "slow-mcp"
714 transport = "stdio"
715 timeout_secs = 30
716 "#;
717
718 let config = ForgeConfig::from_toml(toml).unwrap();
719 assert_eq!(config.servers["slow"].timeout_secs, Some(30));
720 }
721
722 #[test]
723 fn config_server_timeout_defaults_to_none() {
724 let toml = r#"
725 [servers.fast]
726 command = "fast-mcp"
727 transport = "stdio"
728 "#;
729
730 let config = ForgeConfig::from_toml(toml).unwrap();
731 assert!(config.servers["fast"].timeout_secs.is_none());
732 }
733
734 #[test]
735 fn config_parses_circuit_breaker() {
736 let toml = r#"
737 [servers.flaky]
738 command = "flaky-mcp"
739 transport = "stdio"
740 circuit_breaker = true
741 failure_threshold = 5
742 recovery_timeout_secs = 60
743 "#;
744
745 let config = ForgeConfig::from_toml(toml).unwrap();
746 let flaky = &config.servers["flaky"];
747 assert_eq!(flaky.circuit_breaker, Some(true));
748 assert_eq!(flaky.failure_threshold, Some(5));
749 assert_eq!(flaky.recovery_timeout_secs, Some(60));
750 }
751
752 #[test]
753 fn config_circuit_breaker_defaults_to_none() {
754 let toml = r#"
755 [servers.stable]
756 command = "stable-mcp"
757 transport = "stdio"
758 "#;
759
760 let config = ForgeConfig::from_toml(toml).unwrap();
761 let stable = &config.servers["stable"];
762 assert!(stable.circuit_breaker.is_none());
763 assert!(stable.failure_threshold.is_none());
764 assert!(stable.recovery_timeout_secs.is_none());
765 }
766
767 #[test]
768 fn config_execution_mode_defaults_to_none() {
769 let toml = r#"
770 [sandbox]
771 timeout_secs = 5
772 "#;
773
774 let config = ForgeConfig::from_toml(toml).unwrap();
775 assert!(config.sandbox.execution_mode.is_none());
776 }
777
778 #[test]
781 fn cv01_max_resource_size_mb_range() {
782 let toml = "[sandbox]\nmax_resource_size_mb = 7";
784 assert!(ForgeConfig::from_toml(toml).is_ok());
785
786 let toml = "[sandbox]\nmax_resource_size_mb = 0";
788 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
789 assert!(err.contains("max_resource_size_mb"), "got: {err}");
790
791 let toml = "[sandbox]\nmax_resource_size_mb = 513";
793 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
794 assert!(err.contains("max_resource_size_mb"), "got: {err}");
795 }
796
797 #[test]
798 fn cv02_max_parallel_range() {
799 let toml = "[sandbox]\nmax_parallel = 4";
801 assert!(ForgeConfig::from_toml(toml).is_ok());
802
803 let toml = "[sandbox]\nmax_parallel = 0";
805 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
806 assert!(err.contains("max_parallel"), "got: {err}");
807
808 let toml = "[sandbox]\nmax_concurrent = 4\nmax_parallel = 5";
810 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
811 assert!(err.contains("max_parallel"), "got: {err}");
812 }
813
814 #[test]
815 fn cv03_stash_max_value_size_mb_range() {
816 let toml = "[sandbox.stash]\nmax_value_size_mb = 16";
818 assert!(ForgeConfig::from_toml(toml).is_ok());
819
820 let toml = "[sandbox.stash]\nmax_value_size_mb = 0";
822 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
823 assert!(err.contains("max_value_size_mb"), "got: {err}");
824
825 let toml = "[sandbox.stash]\nmax_value_size_mb = 257";
827 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
828 assert!(err.contains("max_value_size_mb"), "got: {err}");
829 }
830
831 #[test]
832 fn cv04_stash_total_size_gte_value_size() {
833 let toml = "[sandbox.stash]\nmax_value_size_mb = 16\nmax_total_size_mb = 128";
835 assert!(ForgeConfig::from_toml(toml).is_ok());
836
837 let toml = "[sandbox.stash]\nmax_value_size_mb = 32\nmax_total_size_mb = 16";
839 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
840 assert!(err.contains("max_total_size_mb"), "got: {err}");
841 }
842
843 #[test]
844 fn cv05_stash_default_ttl_range() {
845 let toml = "[sandbox.stash]\ndefault_ttl_secs = 3600";
847 assert!(ForgeConfig::from_toml(toml).is_ok());
848
849 let toml = "[sandbox.stash]\ndefault_ttl_secs = 0";
851 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
852 assert!(err.contains("default_ttl_secs"), "got: {err}");
853
854 let toml = "[sandbox.stash]\ndefault_ttl_secs = 100000\nmax_ttl_secs = 86400";
856 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
857 assert!(err.contains("default_ttl_secs"), "got: {err}");
858 }
859
860 #[test]
861 fn cv06_stash_max_ttl_range() {
862 let toml = "[sandbox.stash]\nmax_ttl_secs = 86400";
864 assert!(ForgeConfig::from_toml(toml).is_ok());
865
866 let toml = "[sandbox.stash]\nmax_ttl_secs = 0";
868 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
869 assert!(err.contains("max_ttl_secs"), "got: {err}");
870
871 let toml = "[sandbox.stash]\nmax_ttl_secs = 604801";
873 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
874 assert!(err.contains("max_ttl_secs"), "got: {err}");
875 }
876
877 #[test]
878 fn cv07_max_resource_size_fits_ipc() {
879 let toml = "[sandbox]\nmax_resource_size_mb = 7";
881 assert!(ForgeConfig::from_toml(toml).is_ok());
882
883 let toml = "[sandbox]\nmax_resource_size_mb = 8";
885 let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
886 assert!(err.contains("IPC"), "got: {err}");
887
888 let toml = "[sandbox]\nmax_resource_size_mb = 32\nmax_ipc_message_size_mb = 64";
890 assert!(ForgeConfig::from_toml(toml).is_ok());
891 }
892
893 #[test]
894 fn config_parses_v02_sandbox_fields() {
895 let toml = r#"
896 [sandbox]
897 max_resource_size_mb = 7
898 max_ipc_message_size_mb = 64
899 max_parallel = 4
900
901 [sandbox.stash]
902 max_keys = 128
903 max_value_size_mb = 8
904 max_total_size_mb = 64
905 default_ttl_secs = 1800
906 max_ttl_secs = 43200
907 "#;
908
909 let config = ForgeConfig::from_toml(toml).unwrap();
910 assert_eq!(config.sandbox.max_resource_size_mb, Some(7));
911 assert_eq!(config.sandbox.max_ipc_message_size_mb, Some(64));
912 assert_eq!(config.sandbox.max_parallel, Some(4));
913
914 let stash = config.sandbox.stash.unwrap();
915 assert_eq!(stash.max_keys, Some(128));
916 assert_eq!(stash.max_value_size_mb, Some(8));
917 assert_eq!(stash.max_total_size_mb, Some(64));
918 assert_eq!(stash.default_ttl_secs, Some(1800));
919 assert_eq!(stash.max_ttl_secs, Some(43200));
920 }
921}