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