1use std::path::{Path, PathBuf};
54
55use serde_json::{Map, Value};
56
57use crate::config::schema::ServerConfig;
58use crate::error::PodError;
59
60#[derive(Debug, Clone)]
66pub enum ConfigSource {
67 Defaults,
69
70 File(PathBuf),
75
76 EnvVars,
78
79 CliOverlay(Value),
83}
84
85pub(crate) fn resolve_source(source: &ConfigSource) -> Result<Value, PodError> {
94 match source {
95 ConfigSource::Defaults => {
96 let cfg = ServerConfig::default();
99 serde_json::to_value(&cfg).map_err(PodError::Json)
100 }
101
102 ConfigSource::File(path) => load_file(path),
103
104 ConfigSource::EnvVars => Ok(load_env()),
105
106 ConfigSource::CliOverlay(v) => Ok(v.clone()),
107 }
108}
109
110fn load_file(path: &Path) -> Result<Value, PodError> {
111 let content = std::fs::read_to_string(path)
112 .map_err(|e| PodError::Backend(format!("config file {path:?}: {e}")))?;
113
114 let ext = path
117 .extension()
118 .and_then(|e| e.to_str())
119 .map(|s| s.to_ascii_lowercase());
120
121 let v: Value = match ext.as_deref() {
122 #[cfg(feature = "config-loader")]
123 Some("yaml") | Some("yml") => serde_yaml::from_str(&content).map_err(|e| {
124 PodError::Backend(format!("config file {path:?} is not valid YAML: {e}"))
125 })?,
126
127 #[cfg(feature = "config-loader")]
128 Some("toml") => {
129 let toml_v: toml::Value = toml::from_str(&content).map_err(|e| {
130 PodError::Backend(format!("config file {path:?} is not valid TOML: {e}"))
131 })?;
132 serde_json::to_value(toml_v).map_err(PodError::Json)?
134 }
135
136 _ => serde_json::from_str(&content).map_err(|e| {
138 PodError::Backend(format!("config file {path:?} is not valid JSON: {e}"))
139 })?,
140 };
141
142 if !v.is_object() {
143 return Err(PodError::Backend(format!(
144 "config file {path:?}: top-level must be an object, got {}",
145 type_name(&v)
146 )));
147 }
148
149 Ok(normalise_file_shape(v))
153}
154
155fn normalise_file_shape(v: Value) -> Value {
168 let obj = match v {
169 Value::Object(m) => m,
170 other => return other,
171 };
172
173 if obj.contains_key("server") {
175 return Value::Object(obj);
176 }
177
178 let mut out = Map::new();
179 let mut server = Map::new();
180 let mut remaining = Map::new();
181
182 for (k, v) in obj {
183 match k.as_str() {
184 "host" | "port" | "base_url" | "baseUrl" => {
185 let key = if k == "baseUrl" {
187 "base_url".to_string()
188 } else {
189 k
190 };
191 server.insert(key, v);
192 }
193 _ => {
194 remaining.insert(k, v);
195 }
196 }
197 }
198
199 if !server.is_empty() {
200 out.insert("server".to_string(), Value::Object(server));
201 }
202 for (k, v) in remaining {
203 out.insert(k, v);
204 }
205
206 Value::Object(out)
207}
208
209fn load_env() -> Value {
219 env_from(|k| std::env::var(k).ok())
220}
221
222pub(crate) fn env_from<F>(mut get: F) -> Value
224where
225 F: FnMut(&str) -> Option<String>,
226{
227 let mut out = Map::new();
228 let mut server = Map::new();
229 let mut storage = Map::new();
230 let mut auth = Map::new();
231 let mut notifications = Map::new();
232 let mut security = Map::new();
233
234 if let Some(v) = get("JSS_HOST") {
236 server.insert("host".into(), Value::String(v));
237 }
238 if let Some(v) = get("JSS_PORT") {
239 if let Ok(n) = v.parse::<u16>() {
240 server.insert("port".into(), Value::Number(n.into()));
241 }
242 }
243 if let Some(v) = get("JSS_BASE_URL") {
244 server.insert("base_url".into(), Value::String(v));
245 }
246
247 let storage_type = get("JSS_STORAGE_TYPE").map(|s| s.to_ascii_lowercase());
252 let storage_root = get("JSS_STORAGE_ROOT").or_else(|| get("JSS_ROOT"));
253
254 match storage_type.as_deref() {
255 Some("memory") => {
256 storage.insert("type".into(), Value::String("memory".into()));
257 }
261 Some("s3") => {
262 storage.insert("type".into(), Value::String("s3".into()));
263 if let Some(v) = get("JSS_S3_BUCKET") {
264 storage.insert("bucket".into(), Value::String(v));
265 }
266 if let Some(v) = get("JSS_S3_REGION") {
267 storage.insert("region".into(), Value::String(v));
268 }
269 if let Some(v) = get("JSS_S3_PREFIX") {
270 storage.insert("prefix".into(), Value::String(v));
271 }
272 }
273 Some("fs") | None if storage_root.is_some() => {
274 storage.insert("type".into(), Value::String("fs".into()));
275 if let Some(v) = storage_root {
276 storage.insert("root".into(), Value::String(v));
277 }
278 }
279 Some("fs") => {
280 storage.insert("type".into(), Value::String("fs".into()));
281 }
282 Some(_) => {
283 }
285 None => {}
286 }
287
288 if let Some(v) = get("JSS_OIDC_ENABLED").or_else(|| get("JSS_IDP")) {
290 if let Some(b) = parse_bool(&v) {
291 auth.insert("oidc_enabled".into(), Value::Bool(b));
292 }
293 }
294 if let Some(v) = get("JSS_OIDC_ISSUER").or_else(|| get("JSS_IDP_ISSUER")) {
295 auth.insert("oidc_issuer".into(), Value::String(v));
296 }
297 if let Some(v) = get("JSS_NIP98_ENABLED") {
298 if let Some(b) = parse_bool(&v) {
299 auth.insert("nip98_enabled".into(), Value::Bool(b));
300 }
301 }
302 if let Some(v) = get("JSS_DPOP_REPLAY_TTL_SECONDS") {
303 if let Ok(n) = v.parse::<u64>() {
304 auth.insert("dpop_replay_ttl_seconds".into(), Value::Number(n.into()));
305 }
306 }
307
308 let master = get("JSS_NOTIFICATIONS").and_then(|v| parse_bool(&v));
312
313 let ws = get("JSS_NOTIFICATIONS_WS2023")
314 .and_then(|v| parse_bool(&v))
315 .or(master);
316 let webhook = get("JSS_NOTIFICATIONS_WEBHOOK")
317 .and_then(|v| parse_bool(&v))
318 .or(master);
319 let legacy = get("JSS_NOTIFICATIONS_LEGACY")
320 .and_then(|v| parse_bool(&v))
321 .or(master);
322
323 if let Some(b) = ws {
324 notifications.insert("ws2023_enabled".into(), Value::Bool(b));
325 }
326 if let Some(b) = webhook {
327 notifications.insert("webhook2023_enabled".into(), Value::Bool(b));
328 }
329 if let Some(b) = legacy {
330 notifications.insert("legacy_solid_01_enabled".into(), Value::Bool(b));
331 }
332
333 if let Some(v) = get("JSS_SSRF_ALLOW_PRIVATE") {
335 if let Some(b) = parse_bool(&v) {
336 security.insert("ssrf_allow_private".into(), Value::Bool(b));
337 }
338 }
339 if let Some(v) = get("JSS_SSRF_ALLOWLIST") {
340 security.insert("ssrf_allowlist".into(), parse_csv(&v));
341 }
342 if let Some(v) = get("JSS_SSRF_DENYLIST") {
343 security.insert("ssrf_denylist".into(), parse_csv(&v));
344 }
345 if let Some(v) = get("JSS_DOTFILE_ALLOWLIST") {
346 security.insert("dotfile_allowlist".into(), parse_csv(&v));
347 }
348 if let Some(v) = get("JSS_ACL_ORIGIN_ENABLED") {
349 if let Some(b) = parse_bool(&v) {
350 security.insert("acl_origin_enabled".into(), Value::Bool(b));
351 }
352 }
353
354 if let Some(v) = get("JSS_DEFAULT_QUOTA").or_else(|| get("JSS_QUOTA_DEFAULT_BYTES")) {
358 if let Ok(bytes) = parse_size(&v) {
359 security.insert(
360 "default_quota_bytes".into(),
361 Value::Number(bytes.into()),
362 );
363 }
364 }
365
366 let mut extras = Map::new();
376
377 if let Some(v) = get("JSS_CONNEG") {
378 if let Some(b) = parse_bool(&v) {
379 extras.insert("conneg_enabled".into(), Value::Bool(b));
380 }
381 }
382 if let Some(v) = get("JSS_CORS_ALLOWED_ORIGINS") {
383 extras.insert("cors_allowed_origins".into(), parse_csv(&v));
384 }
385 if let Some(v) = get("JSS_MAX_BODY_SIZE").or_else(|| get("JSS_MAX_REQUEST_BODY")) {
386 if let Ok(bytes) = parse_size(&v) {
387 extras.insert("max_body_size_bytes".into(), Value::Number(bytes.into()));
388 }
389 }
390 if let Some(v) = get("JSS_MAX_ACL_BYTES") {
391 if let Ok(bytes) = parse_size(&v) {
392 extras.insert("max_acl_bytes".into(), Value::Number(bytes.into()));
393 }
394 }
395 if let Some(v) = get("JSS_RATE_LIMIT_WRITES_PER_MIN") {
396 if let Ok(n) = v.parse::<u64>() {
397 extras.insert(
398 "rate_limit_writes_per_min".into(),
399 Value::Number(n.into()),
400 );
401 }
402 }
403 if let Some(v) = get("JSS_SUBDOMAINS") {
404 if let Some(b) = parse_bool(&v) {
405 extras.insert("subdomains_enabled".into(), Value::Bool(b));
406 }
407 }
408 if let Some(v) = get("JSS_BASE_DOMAIN") {
409 extras.insert("base_domain".into(), Value::String(v));
410 }
411 if let Some(v) = get("JSS_IDP_ENABLED") {
412 if let Some(b) = parse_bool(&v) {
413 extras.insert("idp_enabled".into(), Value::Bool(b));
414 }
415 }
416 if let Some(v) = get("JSS_INVITE_ONLY") {
417 if let Some(b) = parse_bool(&v) {
418 extras.insert("invite_only".into(), Value::Bool(b));
419 }
420 }
421 if let Some(v) = get("JSS_ADMIN_KEY") {
422 extras.insert("admin_key".into(), Value::String(v));
423 }
424
425 if !server.is_empty() {
426 out.insert("server".into(), Value::Object(server));
427 }
428 if !storage.is_empty() {
429 out.insert("storage".into(), Value::Object(storage));
430 }
431 if !auth.is_empty() {
432 out.insert("auth".into(), Value::Object(auth));
433 }
434 if !notifications.is_empty() {
435 out.insert("notifications".into(), Value::Object(notifications));
436 }
437 if !security.is_empty() {
438 out.insert("security".into(), Value::Object(security));
439 }
440 if !extras.is_empty() {
441 out.insert("extras".into(), Value::Object(extras));
442 }
443
444 Value::Object(out)
445}
446
447pub fn parse_size(s: &str) -> Result<u64, String> {
487 let trimmed = s.trim();
488 if trimmed.is_empty() {
489 return Err("parse_size: empty input".into());
490 }
491
492 let cut = trimmed
494 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
495 .unwrap_or(trimmed.len());
496 let (num_part, suffix_part) = trimmed.split_at(cut);
497 let num_part = num_part.trim();
498 let suffix_raw = suffix_part.trim();
500 let suffix = suffix_raw.to_ascii_uppercase();
501
502 if num_part.is_empty() {
503 return Err(format!("parse_size: missing number in {s:?}"));
504 }
505
506 if num_part.matches('.').count() > 1
508 || num_part.starts_with('.')
509 || num_part.ends_with('.')
510 {
511 return Err(format!("parse_size: invalid number {num_part:?}"));
512 }
513
514 let num: f64 = num_part
515 .parse()
516 .map_err(|e| format!("parse_size: bad number {num_part:?}: {e}"))?;
517
518 if !num.is_finite() || num < 0.0 {
519 return Err(format!("parse_size: non-negative finite number required, got {num}"));
520 }
521
522 let multiplier: u64 = match suffix.as_str() {
526 "" | "B" => 1,
527 "KB" => 1_000,
529 "MB" => 1_000_000,
530 "GB" => 1_000_000_000,
531 "TB" => 1_000_000_000_000,
532 "KIB" => 1_024,
534 "MIB" => 1_024u64.pow(2),
535 "GIB" => 1_024u64.pow(3),
536 "TIB" => 1_024u64.pow(4),
537 other => return Err(format!("parse_size: unknown suffix {other:?}")),
538 };
539
540 let bytes = (num * multiplier as f64).floor();
542 if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
543 return Err(format!("parse_size: result out of u64 range: {bytes}"));
544 }
545 Ok(bytes as u64)
546}
547
548fn parse_bool(s: &str) -> Option<bool> {
549 match s.trim().to_ascii_lowercase().as_str() {
550 "1" | "true" | "yes" | "on" => Some(true),
551 "0" | "false" | "no" | "off" | "" => Some(false),
552 _ => None,
553 }
554}
555
556fn parse_csv(s: &str) -> Value {
557 Value::Array(
558 s.split(',')
559 .map(|p| p.trim())
560 .filter(|p| !p.is_empty())
561 .map(|p| Value::String(p.to_string()))
562 .collect(),
563 )
564}
565
566fn type_name(v: &Value) -> &'static str {
567 match v {
568 Value::Null => "null",
569 Value::Bool(_) => "bool",
570 Value::Number(_) => "number",
571 Value::String(_) => "string",
572 Value::Array(_) => "array",
573 Value::Object(_) => "object",
574 }
575}
576
577pub(crate) fn merge_json(base: &mut Value, overlay: Value) {
589 match (base, overlay) {
590 (Value::Object(b), Value::Object(o)) => {
591 for (k, v) in o {
592 match b.get_mut(&k) {
593 Some(existing) => merge_json(existing, v),
594 None => {
595 b.insert(k, v);
596 }
597 }
598 }
599 }
600 (slot, overlay) => {
601 *slot = overlay;
602 }
603 }
604}
605
606#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn merge_nested_objects_preserves_siblings() {
616 let mut base = serde_json::json!({
617 "server": { "host": "0.0.0.0", "port": 3000 },
618 "auth": { "oidc_enabled": false }
619 });
620 let overlay = serde_json::json!({
621 "server": { "port": 8080 }
622 });
623
624 merge_json(&mut base, overlay);
625
626 assert_eq!(base["server"]["host"], "0.0.0.0");
627 assert_eq!(base["server"]["port"], 8080);
628 assert_eq!(base["auth"]["oidc_enabled"], false);
629 }
630
631 #[test]
632 fn env_host_port() {
633 let v = env_from(|k| match k {
634 "JSS_HOST" => Some("127.0.0.1".into()),
635 "JSS_PORT" => Some("4242".into()),
636 _ => None,
637 });
638 assert_eq!(v["server"]["host"], "127.0.0.1");
639 assert_eq!(v["server"]["port"], 4242);
640 }
641
642 #[test]
643 fn env_memory_storage_ignores_root() {
644 let v = env_from(|k| match k {
645 "JSS_STORAGE_TYPE" => Some("memory".into()),
646 "JSS_STORAGE_ROOT" => Some("/ignored".into()),
647 _ => None,
648 });
649 assert_eq!(v["storage"]["type"], "memory");
650 assert!(v["storage"].get("root").is_none());
651 }
652
653 #[test]
654 fn env_fs_storage_from_jss_root_alias() {
655 let v = env_from(|k| match k {
656 "JSS_ROOT" => Some("/pods".into()),
657 _ => None,
658 });
659 assert_eq!(v["storage"]["type"], "fs");
660 assert_eq!(v["storage"]["root"], "/pods");
661 }
662
663 #[test]
664 fn env_csv_parses_to_array() {
665 let v = env_from(|k| match k {
666 "JSS_SSRF_ALLOWLIST" => Some("10.0.0.0/8, 192.168.1.5".into()),
667 _ => None,
668 });
669 assert_eq!(
670 v["security"]["ssrf_allowlist"],
671 serde_json::json!(["10.0.0.0/8", "192.168.1.5"])
672 );
673 }
674
675 #[test]
676 fn flat_file_shape_normalised_to_nested() {
677 let flat = serde_json::json!({
678 "host": "0.0.0.0",
679 "port": 3000,
680 "baseUrl": "https://example.org",
681 "storage": { "type": "fs", "root": "./data" }
682 });
683 let nested = normalise_file_shape(flat);
684
685 assert_eq!(nested["server"]["host"], "0.0.0.0");
686 assert_eq!(nested["server"]["port"], 3000);
687 assert_eq!(nested["server"]["base_url"], "https://example.org");
688 assert_eq!(nested["storage"]["type"], "fs");
689 }
690
691 #[test]
692 fn nested_file_shape_passes_through() {
693 let nested = serde_json::json!({
694 "server": { "host": "0.0.0.0", "port": 3000 }
695 });
696 let out = normalise_file_shape(nested.clone());
697 assert_eq!(out, nested);
698 }
699}