1use std::path::{Path, PathBuf};
51
52use serde_json::{Map, Value};
53
54use crate::config::schema::ServerConfig;
55use crate::error::PodError;
56
57#[derive(Debug, Clone)]
63pub enum ConfigSource {
64 Defaults,
66
67 File(PathBuf),
72
73 EnvVars,
75
76 CliOverlay(Value),
80}
81
82pub(crate) fn resolve_source(source: &ConfigSource) -> Result<Value, PodError> {
91 match source {
92 ConfigSource::Defaults => {
93 let cfg = ServerConfig::default();
96 serde_json::to_value(&cfg).map_err(PodError::Json)
97 }
98
99 ConfigSource::File(path) => load_file(path),
100
101 ConfigSource::EnvVars => Ok(load_env()),
102
103 ConfigSource::CliOverlay(v) => Ok(v.clone()),
104 }
105}
106
107fn load_file(path: &Path) -> Result<Value, PodError> {
108 let content = std::fs::read_to_string(path)
109 .map_err(|e| PodError::Backend(format!("config file {path:?}: {e}")))?;
110
111 let ext = path
114 .extension()
115 .and_then(|e| e.to_str())
116 .map(|s| s.to_ascii_lowercase());
117
118 let v: Value = match ext.as_deref() {
119 #[cfg(feature = "config-loader")]
120 Some("yaml") | Some("yml") => serde_yaml::from_str(&content).map_err(|e| {
121 PodError::Backend(format!("config file {path:?} is not valid YAML: {e}"))
122 })?,
123
124 #[cfg(feature = "config-loader")]
125 Some("toml") => {
126 let toml_v: toml::Value = toml::from_str(&content).map_err(|e| {
127 PodError::Backend(format!("config file {path:?} is not valid TOML: {e}"))
128 })?;
129 serde_json::to_value(toml_v).map_err(PodError::Json)?
131 }
132
133 _ => serde_json::from_str(&content).map_err(|e| {
135 PodError::Backend(format!("config file {path:?} is not valid JSON: {e}"))
136 })?,
137 };
138
139 if !v.is_object() {
140 return Err(PodError::Backend(format!(
141 "config file {path:?}: top-level must be an object, got {}",
142 type_name(&v)
143 )));
144 }
145
146 Ok(normalise_file_shape(v))
150}
151
152fn normalise_file_shape(v: Value) -> Value {
165 let obj = match v {
166 Value::Object(m) => m,
167 other => return other,
168 };
169
170 if obj.contains_key("server") {
172 return Value::Object(obj);
173 }
174
175 let mut out = Map::new();
176 let mut server = Map::new();
177 let mut remaining = Map::new();
178
179 for (k, v) in obj {
180 match k.as_str() {
181 "host" | "port" | "base_url" | "baseUrl" => {
182 let key = if k == "baseUrl" {
184 "base_url".to_string()
185 } else {
186 k
187 };
188 server.insert(key, v);
189 }
190 _ => {
191 remaining.insert(k, v);
192 }
193 }
194 }
195
196 if !server.is_empty() {
197 out.insert("server".to_string(), Value::Object(server));
198 }
199 for (k, v) in remaining {
200 out.insert(k, v);
201 }
202
203 Value::Object(out)
204}
205
206fn load_env() -> Value {
216 env_from(|k| std::env::var(k).ok())
217}
218
219pub(crate) fn env_from<F>(mut get: F) -> Value
221where
222 F: FnMut(&str) -> Option<String>,
223{
224 let mut out = Map::new();
225 let mut server = Map::new();
226 let mut storage = Map::new();
227 let mut auth = Map::new();
228 let mut notifications = Map::new();
229 let mut security = Map::new();
230
231 if let Some(v) = get("JSS_HOST") {
233 server.insert("host".into(), Value::String(v));
234 }
235 if let Some(v) = get("JSS_PORT") {
236 if let Ok(n) = v.parse::<u16>() {
237 server.insert("port".into(), Value::Number(n.into()));
238 }
239 }
240 if let Some(v) = get("JSS_BASE_URL") {
241 server.insert("base_url".into(), Value::String(v));
242 }
243
244 let storage_type = get("JSS_STORAGE_TYPE").map(|s| s.to_ascii_lowercase());
249 let storage_root = get("JSS_STORAGE_ROOT").or_else(|| get("JSS_ROOT"));
250
251 match storage_type.as_deref() {
252 Some("memory") => {
253 storage.insert("type".into(), Value::String("memory".into()));
254 }
258 Some("fs") | None if storage_root.is_some() => {
259 storage.insert("type".into(), Value::String("fs".into()));
260 if let Some(v) = storage_root {
261 storage.insert("root".into(), Value::String(v));
262 }
263 }
264 Some("fs") => {
265 storage.insert("type".into(), Value::String("fs".into()));
266 }
267 Some(other) => {
268 storage.insert("type".into(), Value::String(other.to_string()));
271 }
272 None => {}
273 }
274
275 if let Some(v) = get("JSS_OIDC_ENABLED").or_else(|| get("JSS_IDP")) {
277 if let Some(b) = parse_bool(&v) {
278 auth.insert("oidc_enabled".into(), Value::Bool(b));
279 }
280 }
281 if let Some(v) = get("JSS_OIDC_ISSUER").or_else(|| get("JSS_IDP_ISSUER")) {
282 auth.insert("oidc_issuer".into(), Value::String(v));
283 }
284 if let Some(v) = get("JSS_NIP98_ENABLED") {
285 if let Some(b) = parse_bool(&v) {
286 auth.insert("nip98_enabled".into(), Value::Bool(b));
287 }
288 }
289 if let Some(v) = get("JSS_DPOP_REPLAY_TTL_SECONDS") {
290 if let Ok(n) = v.parse::<u64>() {
291 auth.insert("dpop_replay_ttl_seconds".into(), Value::Number(n.into()));
292 }
293 }
294
295 let master = get("JSS_NOTIFICATIONS").and_then(|v| parse_bool(&v));
299
300 let ws = get("JSS_NOTIFICATIONS_WS2023")
301 .and_then(|v| parse_bool(&v))
302 .or(master);
303 let webhook = get("JSS_NOTIFICATIONS_WEBHOOK")
304 .and_then(|v| parse_bool(&v))
305 .or(master);
306 let legacy = get("JSS_NOTIFICATIONS_LEGACY")
307 .and_then(|v| parse_bool(&v))
308 .or(master);
309
310 if let Some(b) = ws {
311 notifications.insert("ws2023_enabled".into(), Value::Bool(b));
312 }
313 if let Some(b) = webhook {
314 notifications.insert("webhook2023_enabled".into(), Value::Bool(b));
315 }
316 if let Some(b) = legacy {
317 notifications.insert("legacy_solid_01_enabled".into(), Value::Bool(b));
318 }
319
320 if let Some(v) = get("JSS_SSRF_ALLOW_PRIVATE") {
322 if let Some(b) = parse_bool(&v) {
323 security.insert("ssrf_allow_private".into(), Value::Bool(b));
324 }
325 }
326 if let Some(v) = get("JSS_SSRF_ALLOWLIST") {
327 security.insert("ssrf_allowlist".into(), parse_csv(&v));
328 }
329 if let Some(v) = get("JSS_SSRF_DENYLIST") {
330 security.insert("ssrf_denylist".into(), parse_csv(&v));
331 }
332 if let Some(v) = get("JSS_DOTFILE_ALLOWLIST") {
333 security.insert("dotfile_allowlist".into(), parse_csv(&v));
334 }
335 if let Some(v) = get("JSS_ACL_ORIGIN_ENABLED") {
336 if let Some(b) = parse_bool(&v) {
337 security.insert("acl_origin_enabled".into(), Value::Bool(b));
338 }
339 }
340
341 if let Some(v) = get("JSS_DEFAULT_QUOTA").or_else(|| get("JSS_QUOTA_DEFAULT_BYTES")) {
345 if let Ok(bytes) = parse_size(&v) {
346 security.insert("default_quota_bytes".into(), Value::Number(bytes.into()));
347 }
348 }
349
350 let mut extras = Map::new();
360
361 if let Some(v) = get("JSS_CONNEG") {
362 if let Some(b) = parse_bool(&v) {
363 extras.insert("conneg_enabled".into(), Value::Bool(b));
364 }
365 }
366 if let Some(v) = get("JSS_CORS_ALLOWED_ORIGINS") {
367 extras.insert("cors_allowed_origins".into(), parse_csv(&v));
368 }
369 if let Some(v) = get("JSS_MAX_BODY_SIZE").or_else(|| get("JSS_MAX_REQUEST_BODY")) {
370 if let Ok(bytes) = parse_size(&v) {
371 extras.insert("max_body_size_bytes".into(), Value::Number(bytes.into()));
372 }
373 }
374 if let Some(v) = get("JSS_MAX_ACL_BYTES") {
375 if let Ok(bytes) = parse_size(&v) {
376 extras.insert("max_acl_bytes".into(), Value::Number(bytes.into()));
377 }
378 }
379 if let Some(v) = get("JSS_RATE_LIMIT_WRITES_PER_MIN") {
380 if let Ok(n) = v.parse::<u64>() {
381 extras.insert("rate_limit_writes_per_min".into(), Value::Number(n.into()));
382 }
383 }
384 if let Some(v) = get("JSS_SUBDOMAINS") {
385 if let Some(b) = parse_bool(&v) {
386 extras.insert("subdomains_enabled".into(), Value::Bool(b));
387 }
388 }
389 if let Some(v) = get("JSS_BASE_DOMAIN") {
390 extras.insert("base_domain".into(), Value::String(v));
391 }
392 if let Some(v) = get("JSS_IDP_ENABLED") {
393 if let Some(b) = parse_bool(&v) {
394 extras.insert("idp_enabled".into(), Value::Bool(b));
395 }
396 }
397 if let Some(v) = get("JSS_INVITE_ONLY") {
398 if let Some(b) = parse_bool(&v) {
399 extras.insert("invite_only".into(), Value::Bool(b));
400 }
401 }
402 if let Some(v) = get("JSS_ADMIN_KEY") {
403 extras.insert("admin_key".into(), Value::String(v));
404 }
405
406 if !server.is_empty() {
407 out.insert("server".into(), Value::Object(server));
408 }
409 if !storage.is_empty() {
410 out.insert("storage".into(), Value::Object(storage));
411 }
412 if !auth.is_empty() {
413 out.insert("auth".into(), Value::Object(auth));
414 }
415 if !notifications.is_empty() {
416 out.insert("notifications".into(), Value::Object(notifications));
417 }
418 if !security.is_empty() {
419 out.insert("security".into(), Value::Object(security));
420 }
421 if !extras.is_empty() {
422 out.insert("extras".into(), Value::Object(extras));
423 }
424
425 Value::Object(out)
426}
427
428pub fn parse_size(s: &str) -> Result<u64, String> {
468 let trimmed = s.trim();
469 if trimmed.is_empty() {
470 return Err("parse_size: empty input".into());
471 }
472
473 let cut = trimmed
475 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
476 .unwrap_or(trimmed.len());
477 let (num_part, suffix_part) = trimmed.split_at(cut);
478 let num_part = num_part.trim();
479 let suffix_raw = suffix_part.trim();
481 let suffix = suffix_raw.to_ascii_uppercase();
482
483 if num_part.is_empty() {
484 return Err(format!("parse_size: missing number in {s:?}"));
485 }
486
487 if num_part.matches('.').count() > 1 || num_part.starts_with('.') || num_part.ends_with('.') {
489 return Err(format!("parse_size: invalid number {num_part:?}"));
490 }
491
492 let num: f64 = num_part
493 .parse()
494 .map_err(|e| format!("parse_size: bad number {num_part:?}: {e}"))?;
495
496 if !num.is_finite() || num < 0.0 {
497 return Err(format!(
498 "parse_size: non-negative finite number required, got {num}"
499 ));
500 }
501
502 let multiplier: u64 = match suffix.as_str() {
506 "" | "B" => 1,
507 "KB" => 1_000,
509 "MB" => 1_000_000,
510 "GB" => 1_000_000_000,
511 "TB" => 1_000_000_000_000,
512 "KIB" => 1_024,
514 "MIB" => 1_024u64.pow(2),
515 "GIB" => 1_024u64.pow(3),
516 "TIB" => 1_024u64.pow(4),
517 other => return Err(format!("parse_size: unknown suffix {other:?}")),
518 };
519
520 let bytes = (num * multiplier as f64).floor();
522 if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
523 return Err(format!("parse_size: result out of u64 range: {bytes}"));
524 }
525 Ok(bytes as u64)
526}
527
528fn parse_bool(s: &str) -> Option<bool> {
529 match s.trim().to_ascii_lowercase().as_str() {
530 "1" | "true" | "yes" | "on" => Some(true),
531 "0" | "false" | "no" | "off" | "" => Some(false),
532 _ => None,
533 }
534}
535
536fn parse_csv(s: &str) -> Value {
537 Value::Array(
538 s.split(',')
539 .map(|p| p.trim())
540 .filter(|p| !p.is_empty())
541 .map(|p| Value::String(p.to_string()))
542 .collect(),
543 )
544}
545
546fn type_name(v: &Value) -> &'static str {
547 match v {
548 Value::Null => "null",
549 Value::Bool(_) => "bool",
550 Value::Number(_) => "number",
551 Value::String(_) => "string",
552 Value::Array(_) => "array",
553 Value::Object(_) => "object",
554 }
555}
556
557pub(crate) fn merge_json(base: &mut Value, overlay: Value) {
569 match (base, overlay) {
570 (Value::Object(b), Value::Object(o)) => {
571 for (k, v) in o {
572 match b.get_mut(&k) {
573 Some(existing) => merge_json(existing, v),
574 None => {
575 b.insert(k, v);
576 }
577 }
578 }
579 }
580 (slot, overlay) => {
581 *slot = overlay;
582 }
583 }
584}
585
586#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn merge_nested_objects_preserves_siblings() {
596 let mut base = serde_json::json!({
597 "server": { "host": "0.0.0.0", "port": 3000 },
598 "auth": { "oidc_enabled": false }
599 });
600 let overlay = serde_json::json!({
601 "server": { "port": 8080 }
602 });
603
604 merge_json(&mut base, overlay);
605
606 assert_eq!(base["server"]["host"], "0.0.0.0");
607 assert_eq!(base["server"]["port"], 8080);
608 assert_eq!(base["auth"]["oidc_enabled"], false);
609 }
610
611 #[test]
612 fn env_host_port() {
613 let v = env_from(|k| match k {
614 "JSS_HOST" => Some("127.0.0.1".into()),
615 "JSS_PORT" => Some("4242".into()),
616 _ => None,
617 });
618 assert_eq!(v["server"]["host"], "127.0.0.1");
619 assert_eq!(v["server"]["port"], 4242);
620 }
621
622 #[test]
623 fn env_memory_storage_ignores_root() {
624 let v = env_from(|k| match k {
625 "JSS_STORAGE_TYPE" => Some("memory".into()),
626 "JSS_STORAGE_ROOT" => Some("/ignored".into()),
627 _ => None,
628 });
629 assert_eq!(v["storage"]["type"], "memory");
630 assert!(v["storage"].get("root").is_none());
631 }
632
633 #[test]
634 fn env_fs_storage_from_jss_root_alias() {
635 let v = env_from(|k| match k {
636 "JSS_ROOT" => Some("/pods".into()),
637 _ => None,
638 });
639 assert_eq!(v["storage"]["type"], "fs");
640 assert_eq!(v["storage"]["root"], "/pods");
641 }
642
643 #[test]
644 fn env_unsupported_storage_is_preserved_for_validation_error() {
645 let v = env_from(|k| match k {
646 "JSS_STORAGE_TYPE" => Some("s3".into()),
647 _ => None,
648 });
649 assert_eq!(v["storage"]["type"], "s3");
650 assert!(serde_json::from_value::<super::super::schema::ServerConfig>(v).is_err());
651 }
652
653 #[test]
654 fn env_csv_parses_to_array() {
655 let v = env_from(|k| match k {
656 "JSS_SSRF_ALLOWLIST" => Some("10.0.0.0/8, 192.168.1.5".into()),
657 _ => None,
658 });
659 assert_eq!(
660 v["security"]["ssrf_allowlist"],
661 serde_json::json!(["10.0.0.0/8", "192.168.1.5"])
662 );
663 }
664
665 #[test]
666 fn flat_file_shape_normalised_to_nested() {
667 let flat = serde_json::json!({
668 "host": "0.0.0.0",
669 "port": 3000,
670 "baseUrl": "https://example.org",
671 "storage": { "type": "fs", "root": "./data" }
672 });
673 let nested = normalise_file_shape(flat);
674
675 assert_eq!(nested["server"]["host"], "0.0.0.0");
676 assert_eq!(nested["server"]["port"], 3000);
677 assert_eq!(nested["server"]["base_url"], "https://example.org");
678 assert_eq!(nested["storage"]["type"], "fs");
679 }
680
681 #[test]
682 fn nested_file_shape_passes_through() {
683 let nested = serde_json::json!({
684 "server": { "host": "0.0.0.0", "port": 3000 }
685 });
686 let out = normalise_file_shape(nested.clone());
687 assert_eq!(out, nested);
688 }
689}