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