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_norway::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 for (var, key) in [
406 ("JSS_ACTIVITYPUB", "activitypub_enabled"),
407 ("JSS_TRUST_PROXY", "trust_proxy"),
408 ] {
409 if let Some(b) = get(var).and_then(|v| parse_bool(&v)) {
410 extras.insert(key.into(), Value::Bool(b));
411 }
412 }
413 for (var, key) in [
414 ("JSS_AP_USERNAME", "ap_username"),
415 ("JSS_AP_DISPLAY_NAME", "ap_display_name"),
416 ("JSS_AP_SUMMARY", "ap_summary"),
417 ("JSS_AP_NOSTR_PUBKEY", "ap_nostr_pubkey"),
418 ] {
419 if let Some(v) = get(var) {
420 extras.insert(key.into(), Value::String(v));
421 }
422 }
423 if let Some(v) = get("JSS_DID_NOSTR_ORIGINS") {
424 extras.insert("did_nostr_origins".into(), parse_csv(&v));
425 }
426
427 if !server.is_empty() {
428 out.insert("server".into(), Value::Object(server));
429 }
430 if !storage.is_empty() {
431 out.insert("storage".into(), Value::Object(storage));
432 }
433 if !auth.is_empty() {
434 out.insert("auth".into(), Value::Object(auth));
435 }
436 if !notifications.is_empty() {
437 out.insert("notifications".into(), Value::Object(notifications));
438 }
439 if !security.is_empty() {
440 out.insert("security".into(), Value::Object(security));
441 }
442 if !extras.is_empty() {
443 out.insert("extras".into(), Value::Object(extras));
444 }
445
446 Value::Object(out)
447}
448
449pub fn parse_size(s: &str) -> Result<u64, String> {
489 let trimmed = s.trim();
490 if trimmed.is_empty() {
491 return Err("parse_size: empty input".into());
492 }
493
494 let cut = trimmed
496 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
497 .unwrap_or(trimmed.len());
498 let (num_part, suffix_part) = trimmed.split_at(cut);
499 let num_part = num_part.trim();
500 let suffix_raw = suffix_part.trim();
502 let suffix = suffix_raw.to_ascii_uppercase();
503
504 if num_part.is_empty() {
505 return Err(format!("parse_size: missing number in {s:?}"));
506 }
507
508 if num_part.matches('.').count() > 1 || num_part.starts_with('.') || num_part.ends_with('.') {
510 return Err(format!("parse_size: invalid number {num_part:?}"));
511 }
512
513 let num: f64 = num_part
514 .parse()
515 .map_err(|e| format!("parse_size: bad number {num_part:?}: {e}"))?;
516
517 if !num.is_finite() || num < 0.0 {
518 return Err(format!(
519 "parse_size: non-negative finite number required, got {num}"
520 ));
521 }
522
523 let multiplier: u64 = match suffix.as_str() {
527 "" | "B" => 1,
528 "KB" => 1_000,
530 "MB" => 1_000_000,
531 "GB" => 1_000_000_000,
532 "TB" => 1_000_000_000_000,
533 "KIB" => 1_024,
535 "MIB" => 1_024u64.pow(2),
536 "GIB" => 1_024u64.pow(3),
537 "TIB" => 1_024u64.pow(4),
538 other => return Err(format!("parse_size: unknown suffix {other:?}")),
539 };
540
541 let bytes = (num * multiplier as f64).floor();
543 if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
544 return Err(format!("parse_size: result out of u64 range: {bytes}"));
545 }
546 Ok(bytes as u64)
547}
548
549fn parse_bool(s: &str) -> Option<bool> {
550 match s.trim().to_ascii_lowercase().as_str() {
551 "1" | "true" | "yes" | "on" => Some(true),
552 "0" | "false" | "no" | "off" | "" => Some(false),
553 _ => None,
554 }
555}
556
557fn parse_csv(s: &str) -> Value {
558 Value::Array(
559 s.split(',')
560 .map(|p| p.trim())
561 .filter(|p| !p.is_empty())
562 .map(|p| Value::String(p.to_string()))
563 .collect(),
564 )
565}
566
567fn type_name(v: &Value) -> &'static str {
568 match v {
569 Value::Null => "null",
570 Value::Bool(_) => "bool",
571 Value::Number(_) => "number",
572 Value::String(_) => "string",
573 Value::Array(_) => "array",
574 Value::Object(_) => "object",
575 }
576}
577
578pub(crate) fn merge_json(base: &mut Value, overlay: Value) {
590 match (base, overlay) {
591 (Value::Object(b), Value::Object(o)) => {
592 for (k, v) in o {
593 match b.get_mut(&k) {
594 Some(existing) => merge_json(existing, v),
595 None => {
596 b.insert(k, v);
597 }
598 }
599 }
600 }
601 (slot, overlay) => {
602 *slot = overlay;
603 }
604 }
605}
606
607#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn merge_nested_objects_preserves_siblings() {
617 let mut base = serde_json::json!({
618 "server": { "host": "0.0.0.0", "port": 3000 },
619 "auth": { "oidc_enabled": false }
620 });
621 let overlay = serde_json::json!({
622 "server": { "port": 8080 }
623 });
624
625 merge_json(&mut base, overlay);
626
627 assert_eq!(base["server"]["host"], "0.0.0.0");
628 assert_eq!(base["server"]["port"], 8080);
629 assert_eq!(base["auth"]["oidc_enabled"], false);
630 }
631
632 #[test]
633 fn env_host_port() {
634 let v = env_from(|k| match k {
635 "JSS_HOST" => Some("127.0.0.1".into()),
636 "JSS_PORT" => Some("4242".into()),
637 _ => None,
638 });
639 assert_eq!(v["server"]["host"], "127.0.0.1");
640 assert_eq!(v["server"]["port"], 4242);
641 }
642
643 #[test]
644 fn env_memory_storage_ignores_root() {
645 let v = env_from(|k| match k {
646 "JSS_STORAGE_TYPE" => Some("memory".into()),
647 "JSS_STORAGE_ROOT" => Some("/ignored".into()),
648 _ => None,
649 });
650 assert_eq!(v["storage"]["type"], "memory");
651 assert!(v["storage"].get("root").is_none());
652 }
653
654 #[test]
655 fn env_fs_storage_from_jss_root_alias() {
656 let v = env_from(|k| match k {
657 "JSS_ROOT" => Some("/pods".into()),
658 _ => None,
659 });
660 assert_eq!(v["storage"]["type"], "fs");
661 assert_eq!(v["storage"]["root"], "/pods");
662 }
663
664 #[test]
665 fn env_unsupported_storage_is_preserved_for_validation_error() {
666 let v = env_from(|k| match k {
667 "JSS_STORAGE_TYPE" => Some("s3".into()),
668 _ => None,
669 });
670 assert_eq!(v["storage"]["type"], "s3");
671 assert!(serde_json::from_value::<super::super::schema::ServerConfig>(v).is_err());
672 }
673
674 #[test]
675 fn env_csv_parses_to_array() {
676 let v = env_from(|k| match k {
677 "JSS_SSRF_ALLOWLIST" => Some("10.0.0.0/8, 192.168.1.5".into()),
678 _ => None,
679 });
680 assert_eq!(
681 v["security"]["ssrf_allowlist"],
682 serde_json::json!(["10.0.0.0/8", "192.168.1.5"])
683 );
684 }
685
686 #[test]
687 fn flat_file_shape_normalised_to_nested() {
688 let flat = serde_json::json!({
689 "host": "0.0.0.0",
690 "port": 3000,
691 "baseUrl": "https://example.org",
692 "storage": { "type": "fs", "root": "./data" }
693 });
694 let nested = normalise_file_shape(flat);
695
696 assert_eq!(nested["server"]["host"], "0.0.0.0");
697 assert_eq!(nested["server"]["port"], 3000);
698 assert_eq!(nested["server"]["base_url"], "https://example.org");
699 assert_eq!(nested["storage"]["type"], "fs");
700 }
701
702 #[test]
703 fn nested_file_shape_passes_through() {
704 let nested = serde_json::json!({
705 "server": { "host": "0.0.0.0", "port": 3000 }
706 });
707 let out = normalise_file_shape(nested.clone());
708 assert_eq!(out, nested);
709 }
710}