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("default_quota_bytes".into(), Value::Number(bytes.into()));
360        }
361    }
362
363    // Sprint 11 (row 120-124): JSS parity knobs that surface under the
364    // forward-compat / operator-facing extras. Malformed values ignored
365    // where parsing applies, same forward-compat rule as above.
366    //
367    // These vars do not yet have dedicated serde fields on
368    // `ServerConfig` — they are stored under `extras.*` so operator
369    // scripts can set them today and call sites can consult the loaded
370    // tree via `ServerConfig::extras()` once wired. Keeping the env
371    // map complete means a binary restart with new flags Just Works.
372    let mut extras = Map::new();
373
374    if let Some(v) = get("JSS_CONNEG") {
375        if let Some(b) = parse_bool(&v) {
376            extras.insert("conneg_enabled".into(), Value::Bool(b));
377        }
378    }
379    if let Some(v) = get("JSS_CORS_ALLOWED_ORIGINS") {
380        extras.insert("cors_allowed_origins".into(), parse_csv(&v));
381    }
382    if let Some(v) = get("JSS_MAX_BODY_SIZE").or_else(|| get("JSS_MAX_REQUEST_BODY")) {
383        if let Ok(bytes) = parse_size(&v) {
384            extras.insert("max_body_size_bytes".into(), Value::Number(bytes.into()));
385        }
386    }
387    if let Some(v) = get("JSS_MAX_ACL_BYTES") {
388        if let Ok(bytes) = parse_size(&v) {
389            extras.insert("max_acl_bytes".into(), Value::Number(bytes.into()));
390        }
391    }
392    if let Some(v) = get("JSS_RATE_LIMIT_WRITES_PER_MIN") {
393        if let Ok(n) = v.parse::<u64>() {
394            extras.insert("rate_limit_writes_per_min".into(), Value::Number(n.into()));
395        }
396    }
397    if let Some(v) = get("JSS_SUBDOMAINS") {
398        if let Some(b) = parse_bool(&v) {
399            extras.insert("subdomains_enabled".into(), Value::Bool(b));
400        }
401    }
402    if let Some(v) = get("JSS_BASE_DOMAIN") {
403        extras.insert("base_domain".into(), Value::String(v));
404    }
405    if let Some(v) = get("JSS_IDP_ENABLED") {
406        if let Some(b) = parse_bool(&v) {
407            extras.insert("idp_enabled".into(), Value::Bool(b));
408        }
409    }
410    if let Some(v) = get("JSS_INVITE_ONLY") {
411        if let Some(b) = parse_bool(&v) {
412            extras.insert("invite_only".into(), Value::Bool(b));
413        }
414    }
415    if let Some(v) = get("JSS_ADMIN_KEY") {
416        extras.insert("admin_key".into(), Value::String(v));
417    }
418
419    if !server.is_empty() {
420        out.insert("server".into(), Value::Object(server));
421    }
422    if !storage.is_empty() {
423        out.insert("storage".into(), Value::Object(storage));
424    }
425    if !auth.is_empty() {
426        out.insert("auth".into(), Value::Object(auth));
427    }
428    if !notifications.is_empty() {
429        out.insert("notifications".into(), Value::Object(notifications));
430    }
431    if !security.is_empty() {
432        out.insert("security".into(), Value::Object(security));
433    }
434    if !extras.is_empty() {
435        out.insert("extras".into(), Value::Object(extras));
436    }
437
438    Value::Object(out)
439}
440
441/// Parse a human-friendly size string into bytes.
442///
443/// Accepts a decimal number (optionally with fractional part) followed
444/// by an optional suffix. Whitespace around / between the number and
445/// suffix is tolerated. Empty suffix (or bare digits) is treated as raw
446/// bytes.
447///
448/// # Multipliers
449///
450/// Supports **both SI (decimal, 1000-based) and IEC (binary, 1024-based)**
451/// suffixes; case-insensitive:
452///
453/// | Suffix | Multiplier | Family |
454/// |--------|-----------|--------|
455/// | `B` or bare | 1 | — |
456/// | `KB` | 1_000 | SI |
457/// | `MB` | 1_000_000 | SI |
458/// | `GB` | 1_000_000_000 | SI |
459/// | `TB` | 1_000_000_000_000 | SI |
460/// | `KiB` | 1_024 | IEC |
461/// | `MiB` | 1_024² | IEC |
462/// | `GiB` | 1_024³ | IEC |
463/// | `TiB` | 1_024⁴ | IEC |
464///
465/// Sprint 7 tests relied on SI (`1.5GB → 1_500_000_000`). Sprint 11 adds
466/// IEC suffixes so operators can mirror JSS's native 1024-based sizing
467/// (`50MiB → 50 * 1024 * 1024`). JSS itself accepts only SI-style
468/// suffixes (`K/M/G/T`) but multiplies them by 1024 — neither strictly
469/// matches. We implement both and let the operator pick.
470///
471/// # JSS parity: `src/config.js::parseSize`
472///
473/// ```js
474/// const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i);
475/// ```
476///
477/// Rust mirror: fraction-capable leading number, optional unit, case-
478/// insensitive. JSS falls back to `parseInt(str, 10) || 0` on mismatch;
479/// we return `Err` instead — callers decide whether to default.
480pub fn parse_size(s: &str) -> Result<u64, String> {
481    let trimmed = s.trim();
482    if trimmed.is_empty() {
483        return Err("parse_size: empty input".into());
484    }
485
486    // Split number / suffix at the first non-digit / non-dot char.
487    let cut = trimmed
488        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
489        .unwrap_or(trimmed.len());
490    let (num_part, suffix_part) = trimmed.split_at(cut);
491    let num_part = num_part.trim();
492    // Preserve case for `iB` detection, but match lookups case-insensitively.
493    let suffix_raw = suffix_part.trim();
494    let suffix = suffix_raw.to_ascii_uppercase();
495
496    if num_part.is_empty() {
497        return Err(format!("parse_size: missing number in {s:?}"));
498    }
499
500    // Reject malformed numerics (multi-dot, leading/trailing dot).
501    if num_part.matches('.').count() > 1 || num_part.starts_with('.') || num_part.ends_with('.') {
502        return Err(format!("parse_size: invalid number {num_part:?}"));
503    }
504
505    let num: f64 = num_part
506        .parse()
507        .map_err(|e| format!("parse_size: bad number {num_part:?}: {e}"))?;
508
509    if !num.is_finite() || num < 0.0 {
510        return Err(format!(
511            "parse_size: non-negative finite number required, got {num}"
512        ));
513    }
514
515    // IEC (binary) suffixes carry the `i` between the prefix and B.
516    // Upper-case comparison loses the lowercase `i`, so dispatch via the
517    // already-uppercased suffix (which turns `KiB` into `KIB`).
518    let multiplier: u64 = match suffix.as_str() {
519        "" | "B" => 1,
520        // SI (1000-based)
521        "KB" => 1_000,
522        "MB" => 1_000_000,
523        "GB" => 1_000_000_000,
524        "TB" => 1_000_000_000_000,
525        // IEC (1024-based) — case-insensitive match since we upper-cased.
526        "KIB" => 1_024,
527        "MIB" => 1_024u64.pow(2),
528        "GIB" => 1_024u64.pow(3),
529        "TIB" => 1_024u64.pow(4),
530        other => return Err(format!("parse_size: unknown suffix {other:?}")),
531    };
532
533    // floor(num * multiplier) — match JSS Math.floor behaviour.
534    let bytes = (num * multiplier as f64).floor();
535    if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
536        return Err(format!("parse_size: result out of u64 range: {bytes}"));
537    }
538    Ok(bytes as u64)
539}
540
541fn parse_bool(s: &str) -> Option<bool> {
542    match s.trim().to_ascii_lowercase().as_str() {
543        "1" | "true" | "yes" | "on" => Some(true),
544        "0" | "false" | "no" | "off" | "" => Some(false),
545        _ => None,
546    }
547}
548
549fn parse_csv(s: &str) -> Value {
550    Value::Array(
551        s.split(',')
552            .map(|p| p.trim())
553            .filter(|p| !p.is_empty())
554            .map(|p| Value::String(p.to_string()))
555            .collect(),
556    )
557}
558
559fn type_name(v: &Value) -> &'static str {
560    match v {
561        Value::Null => "null",
562        Value::Bool(_) => "bool",
563        Value::Number(_) => "number",
564        Value::String(_) => "string",
565        Value::Array(_) => "array",
566        Value::Object(_) => "object",
567    }
568}
569
570// ---------------------------------------------------------------------------
571// Merge logic
572// ---------------------------------------------------------------------------
573
574/// Recursively deep-merge `overlay` into `base`. Objects are merged
575/// key-by-key; non-object leaves are replaced wholesale.
576///
577/// This matches JSS's shallow-spread behaviour at the top level
578/// (`{...defaults, ...fileConfig, ...envConfig}` — `config.js:219`)
579/// but extends it to nested objects so a partial `server` override
580/// doesn't wipe unset siblings.
581pub(crate) fn merge_json(base: &mut Value, overlay: Value) {
582    match (base, overlay) {
583        (Value::Object(b), Value::Object(o)) => {
584            for (k, v) in o {
585                match b.get_mut(&k) {
586                    Some(existing) => merge_json(existing, v),
587                    None => {
588                        b.insert(k, v);
589                    }
590                }
591            }
592        }
593        (slot, overlay) => {
594            *slot = overlay;
595        }
596    }
597}
598
599// ---------------------------------------------------------------------------
600// Tests
601// ---------------------------------------------------------------------------
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn merge_nested_objects_preserves_siblings() {
609        let mut base = serde_json::json!({
610            "server": { "host": "0.0.0.0", "port": 3000 },
611            "auth":   { "oidc_enabled": false }
612        });
613        let overlay = serde_json::json!({
614            "server": { "port": 8080 }
615        });
616
617        merge_json(&mut base, overlay);
618
619        assert_eq!(base["server"]["host"], "0.0.0.0");
620        assert_eq!(base["server"]["port"], 8080);
621        assert_eq!(base["auth"]["oidc_enabled"], false);
622    }
623
624    #[test]
625    fn env_host_port() {
626        let v = env_from(|k| match k {
627            "JSS_HOST" => Some("127.0.0.1".into()),
628            "JSS_PORT" => Some("4242".into()),
629            _ => None,
630        });
631        assert_eq!(v["server"]["host"], "127.0.0.1");
632        assert_eq!(v["server"]["port"], 4242);
633    }
634
635    #[test]
636    fn env_memory_storage_ignores_root() {
637        let v = env_from(|k| match k {
638            "JSS_STORAGE_TYPE" => Some("memory".into()),
639            "JSS_STORAGE_ROOT" => Some("/ignored".into()),
640            _ => None,
641        });
642        assert_eq!(v["storage"]["type"], "memory");
643        assert!(v["storage"].get("root").is_none());
644    }
645
646    #[test]
647    fn env_fs_storage_from_jss_root_alias() {
648        let v = env_from(|k| match k {
649            "JSS_ROOT" => Some("/pods".into()),
650            _ => None,
651        });
652        assert_eq!(v["storage"]["type"], "fs");
653        assert_eq!(v["storage"]["root"], "/pods");
654    }
655
656    #[test]
657    fn env_csv_parses_to_array() {
658        let v = env_from(|k| match k {
659            "JSS_SSRF_ALLOWLIST" => Some("10.0.0.0/8, 192.168.1.5".into()),
660            _ => None,
661        });
662        assert_eq!(
663            v["security"]["ssrf_allowlist"],
664            serde_json::json!(["10.0.0.0/8", "192.168.1.5"])
665        );
666    }
667
668    #[test]
669    fn flat_file_shape_normalised_to_nested() {
670        let flat = serde_json::json!({
671            "host": "0.0.0.0",
672            "port": 3000,
673            "baseUrl": "https://example.org",
674            "storage": { "type": "fs", "root": "./data" }
675        });
676        let nested = normalise_file_shape(flat);
677
678        assert_eq!(nested["server"]["host"], "0.0.0.0");
679        assert_eq!(nested["server"]["port"], 3000);
680        assert_eq!(nested["server"]["base_url"], "https://example.org");
681        assert_eq!(nested["storage"]["type"], "fs");
682    }
683
684    #[test]
685    fn nested_file_shape_passes_through() {
686        let nested = serde_json::json!({
687            "server": { "host": "0.0.0.0", "port": 3000 }
688        });
689        let out = normalise_file_shape(nested.clone());
690        assert_eq!(out, nested);
691    }
692}