Skip to main content

solid_pod_rs/config/
sources.rs

1//! Config source precedence + merge logic.
2//!
3//! # JSS env var mapping (canonical `JSS_*` prefix)
4//!
5//! The loader honours the following env vars 1:1 with their JSS
6//! semantics. Where a var is listed with `[TODO verify JSS]` it means
7//! solid-pod-rs introduces it to parity a Rust-side primitive that JSS
8//! handles implicitly or not at all.
9//!
10//! | Env var | Maps to | JSS source |
11//! |---|---|---|
12//! | `JSS_HOST` | `server.host` | `config.js:98` |
13//! | `JSS_PORT` | `server.port` | `config.js:97` |
14//! | `JSS_BASE_URL` | `server.base_url` | `config.js:*` (bin/jss.js) |
15//! | `JSS_ROOT` | `storage.Fs{root}` (fs kind only) | `config.js:99` |
16//! | `JSS_STORAGE_TYPE` | `storage.type` (`fs`/`memory`) | JSS uses storage adapters via `config.json`; env wrapper added here for CLI parity |
17//! | `JSS_STORAGE_ROOT` | `storage.Fs{root}` | alias for `JSS_ROOT` restricted to fs backend |
18//! | `JSS_OIDC_ENABLED` | `auth.oidc_enabled` | JSS uses `JSS_IDP` (config.js:107); `JSS_IDP` accepted as alias |
19//! | `JSS_IDP` | `auth.oidc_enabled` (alias of `JSS_OIDC_ENABLED`) | `config.js:107` |
20//! | `JSS_OIDC_ISSUER` | `auth.oidc_issuer` | JSS `JSS_IDP_ISSUER` (config.js:108); `JSS_IDP_ISSUER` accepted as alias |
21//! | `JSS_IDP_ISSUER` | `auth.oidc_issuer` (alias) | `config.js:108` |
22//! | `JSS_DPOP_REPLAY_TTL_SECONDS` | `auth.dpop_replay_ttl_seconds` | `[TODO verify JSS]` — Rust-side DPoP cache tuning |
23//! | `JSS_NOTIFICATIONS_WS2023` | `notifications.ws2023_enabled` | subset of JSS `JSS_NOTIFICATIONS` (config.js:104) |
24//! | `JSS_NOTIFICATIONS_WEBHOOK` | `notifications.webhook2023_enabled` | subset of JSS `JSS_NOTIFICATIONS` |
25//! | `JSS_NOTIFICATIONS_LEGACY` | `notifications.legacy_solid_01_enabled` | subset of JSS `JSS_NOTIFICATIONS` |
26//! | `JSS_NOTIFICATIONS` | toggles **all three** notification channels on/off | `config.js:104` (coarse master switch) |
27//! | `JSS_SSRF_ALLOW_PRIVATE` | `security.ssrf_allow_private` | `[TODO verify JSS]` — F1 security primitive |
28//! | `JSS_SSRF_ALLOWLIST` | `security.ssrf_allowlist` (comma-separated) | `[TODO verify JSS]` |
29//! | `JSS_SSRF_DENYLIST` | `security.ssrf_denylist` (comma-separated) | `[TODO verify JSS]` |
30//! | `JSS_DOTFILE_ALLOWLIST` | `security.dotfile_allowlist` (comma-separated) | `[TODO verify JSS]` |
31//! | `JSS_ACL_ORIGIN_ENABLED` | `security.acl_origin_enabled` | `[TODO verify JSS]` — F4 primitive |
32//!
33//! Unknown `JSS_*` vars are **ignored silently** at the sources layer
34//! (warnings are a loader-level concern, see
35//! [`crate::config::loader::ConfigLoader`]). This supports forward
36//! compat with newer JSS releases.
37//!
38//! # Precedence
39//!
40//! ```text
41//! Defaults  <  File  <  EnvVars
42//! (lowest)                (highest)
43//! ```
44//!
45//! Later sources overwrite earlier ones, matching JSS's
46//! `{...defaults, ...fileConfig, ...envConfig}` model
47//! (`config.js:219-224`). CLI overlay (if added later) would sit above
48//! env vars.
49
50use std::path::{Path, PathBuf};
51
52use serde_json::{Map, Value};
53
54use crate::config::schema::ServerConfig;
55use crate::error::PodError;
56
57// ---------------------------------------------------------------------------
58// ConfigSource
59// ---------------------------------------------------------------------------
60
61/// One layer of the precedence stack.
62#[derive(Debug, Clone)]
63pub enum ConfigSource {
64    /// Hard-coded defaults (always first).
65    Defaults,
66
67    /// Config file at the given path. Format auto-detected from the
68    /// extension: `.json`, `.yaml`/`.yml`, `.toml` (YAML/TOML require
69    /// the `config-loader` feature). Missing / malformed is a hard
70    /// error; unknown fields are tolerated.
71    File(PathBuf),
72
73    /// Read `JSS_*` env vars from `std::env`.
74    EnvVars,
75
76    /// Sprint 11 (row 121): highest-precedence CLI overlay, carried as
77    /// a pre-built JSON value so the loader can deep-merge it without
78    /// caring about the CLI parser.
79    CliOverlay(Value),
80}
81
82// ---------------------------------------------------------------------------
83// Resolution / merging
84// ---------------------------------------------------------------------------
85
86/// Resolve a source into a JSON value tree.
87///
88/// The returned value is a `serde_json::Value::Object` that is merged
89/// into the accumulator by [`merge_json`] in precedence order.
90pub(crate) fn resolve_source(source: &ConfigSource) -> Result<Value, PodError> {
91    match source {
92        ConfigSource::Defaults => {
93            // Serialise the Default impl; this gives us the same
94            // structure as a file-sourced config for easy merging.
95            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    // Auto-detect format from extension. Unknown extensions fall back to
112    // JSON, preserving the Sprint-4 behaviour.
113    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            // Convert toml::Value -> serde_json::Value via serde round-trip.
130            serde_json::to_value(toml_v).map_err(PodError::Json)?
131        }
132
133        // Default / JSON extension / config-loader off: try JSON.
134        _ => 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    // JSS accepts a flat config.json (host/port at root). Normalise
147    // both flat and nested shapes into the nested ServerConfig
148    // structure that ServerConfig expects.
149    Ok(normalise_file_shape(v))
150}
151
152/// Translate a JSS-style flat `config.json` into solid-pod-rs's nested
153/// shape. A nested config passes through untouched.
154///
155/// JSS flat:
156/// ```json
157/// { "host": "0.0.0.0", "port": 3000, "storage": { "type": "fs", "root": "./data" } }
158/// ```
159///
160/// Nested (solid-pod-rs native):
161/// ```json
162/// { "server": { "host": "…", "port": 3000 }, "storage": {…} }
163/// ```
164fn normalise_file_shape(v: Value) -> Value {
165    let obj = match v {
166        Value::Object(m) => m,
167        other => return other,
168    };
169
170    // If a `server` key already exists, assume nested shape — pass through.
171    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                // camelCase → snake_case for baseUrl
183                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
206// ---------------------------------------------------------------------------
207// Env var loading
208// ---------------------------------------------------------------------------
209
210/// Read the known `JSS_*` env vars and build a sparse JSON object
211/// reflecting whichever were set.
212///
213/// Unknown `JSS_*` vars are ignored (warnings happen at the loader
214/// level if requested).
215fn load_env() -> Value {
216    env_from(|k| std::env::var(k).ok())
217}
218
219/// Test-friendly variant that reads env via a closure.
220pub(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    // --- server.*
232    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    // --- storage.*
245    //
246    // Precedence inside storage: JSS_STORAGE_TYPE > (JSS_STORAGE_ROOT | JSS_ROOT)
247    // A bare JSS_ROOT implies fs backend.
248    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            // JSS_STORAGE_ROOT=... while JSS_STORAGE_TYPE=memory is
255            // nonsensical; loader emits a warning. Here we honour
256            // memory and drop root.
257        }
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            // Preserve unsupported values so tagged-enum deserialisation fails
269            // clearly instead of silently falling back to filesystem storage.
270            storage.insert("type".into(), Value::String(other.to_string()));
271        }
272        None => {}
273    }
274
275    // --- auth.*
276    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    // --- notifications.*
296    // Coarse master switch — drives all three sub-toggles if individual
297    // toggles aren't set.
298    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    // --- security.*
321    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    // Sprint 7: JSS_DEFAULT_QUOTA decoded via parse_size (`50MB`, `1.5GB`).
342    // Surfaces under `security.default_quota_bytes` when valid;
343    // malformed values are ignored (forward-compat with unknown units).
344    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    // Sprint 11 (row 120-124): JSS parity knobs that surface under the
351    // forward-compat / operator-facing extras. Malformed values ignored
352    // where parsing applies, same forward-compat rule as above.
353    //
354    // These vars do not yet have dedicated serde fields on
355    // `ServerConfig` — they are stored under `extras.*` so operator
356    // scripts can set them today and call sites can consult the loaded
357    // tree via `ServerConfig::extras()` once wired. Keeping the env
358    // map complete means a binary restart with new flags Just Works.
359    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
449/// Parse a human-friendly size string into bytes.
450///
451/// Accepts a decimal number (optionally with fractional part) followed
452/// by an optional suffix. Whitespace around / between the number and
453/// suffix is tolerated. Empty suffix (or bare digits) is treated as raw
454/// bytes.
455///
456/// # Multipliers
457///
458/// Supports **both SI (decimal, 1000-based) and IEC (binary, 1024-based)**
459/// suffixes; case-insensitive:
460///
461/// | Suffix | Multiplier | Family |
462/// |--------|-----------|--------|
463/// | `B` or bare | 1 | — |
464/// | `KB` | 1_000 | SI |
465/// | `MB` | 1_000_000 | SI |
466/// | `GB` | 1_000_000_000 | SI |
467/// | `TB` | 1_000_000_000_000 | SI |
468/// | `KiB` | 1_024 | IEC |
469/// | `MiB` | 1_024² | IEC |
470/// | `GiB` | 1_024³ | IEC |
471/// | `TiB` | 1_024⁴ | IEC |
472///
473/// Sprint 7 tests relied on SI (`1.5GB → 1_500_000_000`). Sprint 11 adds
474/// IEC suffixes so operators can mirror JSS's native 1024-based sizing
475/// (`50MiB → 50 * 1024 * 1024`). JSS itself accepts only SI-style
476/// suffixes (`K/M/G/T`) but multiplies them by 1024 — neither strictly
477/// matches. We implement both and let the operator pick.
478///
479/// # JSS parity: `src/config.js::parseSize`
480///
481/// ```js
482/// const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i);
483/// ```
484///
485/// Rust mirror: fraction-capable leading number, optional unit, case-
486/// insensitive. JSS falls back to `parseInt(str, 10) || 0` on mismatch;
487/// we return `Err` instead — callers decide whether to default.
488pub 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    // Split number / suffix at the first non-digit / non-dot char.
495    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    // Preserve case for `iB` detection, but match lookups case-insensitively.
501    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    // Reject malformed numerics (multi-dot, leading/trailing dot).
509    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    // IEC (binary) suffixes carry the `i` between the prefix and B.
524    // Upper-case comparison loses the lowercase `i`, so dispatch via the
525    // already-uppercased suffix (which turns `KiB` into `KIB`).
526    let multiplier: u64 = match suffix.as_str() {
527        "" | "B" => 1,
528        // SI (1000-based)
529        "KB" => 1_000,
530        "MB" => 1_000_000,
531        "GB" => 1_000_000_000,
532        "TB" => 1_000_000_000_000,
533        // IEC (1024-based) — case-insensitive match since we upper-cased.
534        "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    // floor(num * multiplier) — match JSS Math.floor behaviour.
542    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
578// ---------------------------------------------------------------------------
579// Merge logic
580// ---------------------------------------------------------------------------
581
582/// Recursively deep-merge `overlay` into `base`. Objects are merged
583/// key-by-key; non-object leaves are replaced wholesale.
584///
585/// This matches JSS's shallow-spread behaviour at the top level
586/// (`{...defaults, ...fileConfig, ...envConfig}` — `config.js:219`)
587/// but extends it to nested objects so a partial `server` override
588/// doesn't wipe unset siblings.
589pub(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// ---------------------------------------------------------------------------
608// Tests
609// ---------------------------------------------------------------------------
610
611#[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}