use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use crate::config::schema::ServerConfig;
use crate::error::PodError;
#[derive(Debug, Clone)]
pub enum ConfigSource {
Defaults,
File(PathBuf),
EnvVars,
CliOverlay(Value),
}
pub(crate) fn resolve_source(source: &ConfigSource) -> Result<Value, PodError> {
match source {
ConfigSource::Defaults => {
let cfg = ServerConfig::default();
serde_json::to_value(&cfg).map_err(PodError::Json)
}
ConfigSource::File(path) => load_file(path),
ConfigSource::EnvVars => Ok(load_env()),
ConfigSource::CliOverlay(v) => Ok(v.clone()),
}
}
fn load_file(path: &Path) -> Result<Value, PodError> {
let content = std::fs::read_to_string(path)
.map_err(|e| PodError::Backend(format!("config file {path:?}: {e}")))?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase());
let v: Value = match ext.as_deref() {
#[cfg(feature = "config-loader")]
Some("yaml") | Some("yml") => serde_yaml::from_str(&content).map_err(|e| {
PodError::Backend(format!("config file {path:?} is not valid YAML: {e}"))
})?,
#[cfg(feature = "config-loader")]
Some("toml") => {
let toml_v: toml::Value = toml::from_str(&content).map_err(|e| {
PodError::Backend(format!("config file {path:?} is not valid TOML: {e}"))
})?;
serde_json::to_value(toml_v).map_err(PodError::Json)?
}
_ => serde_json::from_str(&content).map_err(|e| {
PodError::Backend(format!("config file {path:?} is not valid JSON: {e}"))
})?,
};
if !v.is_object() {
return Err(PodError::Backend(format!(
"config file {path:?}: top-level must be an object, got {}",
type_name(&v)
)));
}
Ok(normalise_file_shape(v))
}
fn normalise_file_shape(v: Value) -> Value {
let obj = match v {
Value::Object(m) => m,
other => return other,
};
if obj.contains_key("server") {
return Value::Object(obj);
}
let mut out = Map::new();
let mut server = Map::new();
let mut remaining = Map::new();
for (k, v) in obj {
match k.as_str() {
"host" | "port" | "base_url" | "baseUrl" => {
let key = if k == "baseUrl" {
"base_url".to_string()
} else {
k
};
server.insert(key, v);
}
_ => {
remaining.insert(k, v);
}
}
}
if !server.is_empty() {
out.insert("server".to_string(), Value::Object(server));
}
for (k, v) in remaining {
out.insert(k, v);
}
Value::Object(out)
}
fn load_env() -> Value {
env_from(|k| std::env::var(k).ok())
}
pub(crate) fn env_from<F>(mut get: F) -> Value
where
F: FnMut(&str) -> Option<String>,
{
let mut out = Map::new();
let mut server = Map::new();
let mut storage = Map::new();
let mut auth = Map::new();
let mut notifications = Map::new();
let mut security = Map::new();
if let Some(v) = get("JSS_HOST") {
server.insert("host".into(), Value::String(v));
}
if let Some(v) = get("JSS_PORT") {
if let Ok(n) = v.parse::<u16>() {
server.insert("port".into(), Value::Number(n.into()));
}
}
if let Some(v) = get("JSS_BASE_URL") {
server.insert("base_url".into(), Value::String(v));
}
let storage_type = get("JSS_STORAGE_TYPE").map(|s| s.to_ascii_lowercase());
let storage_root = get("JSS_STORAGE_ROOT").or_else(|| get("JSS_ROOT"));
match storage_type.as_deref() {
Some("memory") => {
storage.insert("type".into(), Value::String("memory".into()));
}
Some("s3") => {
storage.insert("type".into(), Value::String("s3".into()));
if let Some(v) = get("JSS_S3_BUCKET") {
storage.insert("bucket".into(), Value::String(v));
}
if let Some(v) = get("JSS_S3_REGION") {
storage.insert("region".into(), Value::String(v));
}
if let Some(v) = get("JSS_S3_PREFIX") {
storage.insert("prefix".into(), Value::String(v));
}
}
Some("fs") | None if storage_root.is_some() => {
storage.insert("type".into(), Value::String("fs".into()));
if let Some(v) = storage_root {
storage.insert("root".into(), Value::String(v));
}
}
Some("fs") => {
storage.insert("type".into(), Value::String("fs".into()));
}
Some(_) => {
}
None => {}
}
if let Some(v) = get("JSS_OIDC_ENABLED").or_else(|| get("JSS_IDP")) {
if let Some(b) = parse_bool(&v) {
auth.insert("oidc_enabled".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_OIDC_ISSUER").or_else(|| get("JSS_IDP_ISSUER")) {
auth.insert("oidc_issuer".into(), Value::String(v));
}
if let Some(v) = get("JSS_NIP98_ENABLED") {
if let Some(b) = parse_bool(&v) {
auth.insert("nip98_enabled".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_DPOP_REPLAY_TTL_SECONDS") {
if let Ok(n) = v.parse::<u64>() {
auth.insert("dpop_replay_ttl_seconds".into(), Value::Number(n.into()));
}
}
let master = get("JSS_NOTIFICATIONS").and_then(|v| parse_bool(&v));
let ws = get("JSS_NOTIFICATIONS_WS2023")
.and_then(|v| parse_bool(&v))
.or(master);
let webhook = get("JSS_NOTIFICATIONS_WEBHOOK")
.and_then(|v| parse_bool(&v))
.or(master);
let legacy = get("JSS_NOTIFICATIONS_LEGACY")
.and_then(|v| parse_bool(&v))
.or(master);
if let Some(b) = ws {
notifications.insert("ws2023_enabled".into(), Value::Bool(b));
}
if let Some(b) = webhook {
notifications.insert("webhook2023_enabled".into(), Value::Bool(b));
}
if let Some(b) = legacy {
notifications.insert("legacy_solid_01_enabled".into(), Value::Bool(b));
}
if let Some(v) = get("JSS_SSRF_ALLOW_PRIVATE") {
if let Some(b) = parse_bool(&v) {
security.insert("ssrf_allow_private".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_SSRF_ALLOWLIST") {
security.insert("ssrf_allowlist".into(), parse_csv(&v));
}
if let Some(v) = get("JSS_SSRF_DENYLIST") {
security.insert("ssrf_denylist".into(), parse_csv(&v));
}
if let Some(v) = get("JSS_DOTFILE_ALLOWLIST") {
security.insert("dotfile_allowlist".into(), parse_csv(&v));
}
if let Some(v) = get("JSS_ACL_ORIGIN_ENABLED") {
if let Some(b) = parse_bool(&v) {
security.insert("acl_origin_enabled".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_DEFAULT_QUOTA").or_else(|| get("JSS_QUOTA_DEFAULT_BYTES")) {
if let Ok(bytes) = parse_size(&v) {
security.insert(
"default_quota_bytes".into(),
Value::Number(bytes.into()),
);
}
}
let mut extras = Map::new();
if let Some(v) = get("JSS_CONNEG") {
if let Some(b) = parse_bool(&v) {
extras.insert("conneg_enabled".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_CORS_ALLOWED_ORIGINS") {
extras.insert("cors_allowed_origins".into(), parse_csv(&v));
}
if let Some(v) = get("JSS_MAX_BODY_SIZE").or_else(|| get("JSS_MAX_REQUEST_BODY")) {
if let Ok(bytes) = parse_size(&v) {
extras.insert("max_body_size_bytes".into(), Value::Number(bytes.into()));
}
}
if let Some(v) = get("JSS_MAX_ACL_BYTES") {
if let Ok(bytes) = parse_size(&v) {
extras.insert("max_acl_bytes".into(), Value::Number(bytes.into()));
}
}
if let Some(v) = get("JSS_RATE_LIMIT_WRITES_PER_MIN") {
if let Ok(n) = v.parse::<u64>() {
extras.insert(
"rate_limit_writes_per_min".into(),
Value::Number(n.into()),
);
}
}
if let Some(v) = get("JSS_SUBDOMAINS") {
if let Some(b) = parse_bool(&v) {
extras.insert("subdomains_enabled".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_BASE_DOMAIN") {
extras.insert("base_domain".into(), Value::String(v));
}
if let Some(v) = get("JSS_IDP_ENABLED") {
if let Some(b) = parse_bool(&v) {
extras.insert("idp_enabled".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_INVITE_ONLY") {
if let Some(b) = parse_bool(&v) {
extras.insert("invite_only".into(), Value::Bool(b));
}
}
if let Some(v) = get("JSS_ADMIN_KEY") {
extras.insert("admin_key".into(), Value::String(v));
}
if !server.is_empty() {
out.insert("server".into(), Value::Object(server));
}
if !storage.is_empty() {
out.insert("storage".into(), Value::Object(storage));
}
if !auth.is_empty() {
out.insert("auth".into(), Value::Object(auth));
}
if !notifications.is_empty() {
out.insert("notifications".into(), Value::Object(notifications));
}
if !security.is_empty() {
out.insert("security".into(), Value::Object(security));
}
if !extras.is_empty() {
out.insert("extras".into(), Value::Object(extras));
}
Value::Object(out)
}
pub fn parse_size(s: &str) -> Result<u64, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("parse_size: empty input".into());
}
let cut = trimmed
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(trimmed.len());
let (num_part, suffix_part) = trimmed.split_at(cut);
let num_part = num_part.trim();
let suffix_raw = suffix_part.trim();
let suffix = suffix_raw.to_ascii_uppercase();
if num_part.is_empty() {
return Err(format!("parse_size: missing number in {s:?}"));
}
if num_part.matches('.').count() > 1
|| num_part.starts_with('.')
|| num_part.ends_with('.')
{
return Err(format!("parse_size: invalid number {num_part:?}"));
}
let num: f64 = num_part
.parse()
.map_err(|e| format!("parse_size: bad number {num_part:?}: {e}"))?;
if !num.is_finite() || num < 0.0 {
return Err(format!("parse_size: non-negative finite number required, got {num}"));
}
let multiplier: u64 = match suffix.as_str() {
"" | "B" => 1,
"KB" => 1_000,
"MB" => 1_000_000,
"GB" => 1_000_000_000,
"TB" => 1_000_000_000_000,
"KIB" => 1_024,
"MIB" => 1_024u64.pow(2),
"GIB" => 1_024u64.pow(3),
"TIB" => 1_024u64.pow(4),
other => return Err(format!("parse_size: unknown suffix {other:?}")),
};
let bytes = (num * multiplier as f64).floor();
if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 {
return Err(format!("parse_size: result out of u64 range: {bytes}"));
}
Ok(bytes as u64)
}
fn parse_bool(s: &str) -> Option<bool> {
match s.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" | "" => Some(false),
_ => None,
}
}
fn parse_csv(s: &str) -> Value {
Value::Array(
s.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| Value::String(p.to_string()))
.collect(),
)
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub(crate) fn merge_json(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o {
match b.get_mut(&k) {
Some(existing) => merge_json(existing, v),
None => {
b.insert(k, v);
}
}
}
}
(slot, overlay) => {
*slot = overlay;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_nested_objects_preserves_siblings() {
let mut base = serde_json::json!({
"server": { "host": "0.0.0.0", "port": 3000 },
"auth": { "oidc_enabled": false }
});
let overlay = serde_json::json!({
"server": { "port": 8080 }
});
merge_json(&mut base, overlay);
assert_eq!(base["server"]["host"], "0.0.0.0");
assert_eq!(base["server"]["port"], 8080);
assert_eq!(base["auth"]["oidc_enabled"], false);
}
#[test]
fn env_host_port() {
let v = env_from(|k| match k {
"JSS_HOST" => Some("127.0.0.1".into()),
"JSS_PORT" => Some("4242".into()),
_ => None,
});
assert_eq!(v["server"]["host"], "127.0.0.1");
assert_eq!(v["server"]["port"], 4242);
}
#[test]
fn env_memory_storage_ignores_root() {
let v = env_from(|k| match k {
"JSS_STORAGE_TYPE" => Some("memory".into()),
"JSS_STORAGE_ROOT" => Some("/ignored".into()),
_ => None,
});
assert_eq!(v["storage"]["type"], "memory");
assert!(v["storage"].get("root").is_none());
}
#[test]
fn env_fs_storage_from_jss_root_alias() {
let v = env_from(|k| match k {
"JSS_ROOT" => Some("/pods".into()),
_ => None,
});
assert_eq!(v["storage"]["type"], "fs");
assert_eq!(v["storage"]["root"], "/pods");
}
#[test]
fn env_csv_parses_to_array() {
let v = env_from(|k| match k {
"JSS_SSRF_ALLOWLIST" => Some("10.0.0.0/8, 192.168.1.5".into()),
_ => None,
});
assert_eq!(
v["security"]["ssrf_allowlist"],
serde_json::json!(["10.0.0.0/8", "192.168.1.5"])
);
}
#[test]
fn flat_file_shape_normalised_to_nested() {
let flat = serde_json::json!({
"host": "0.0.0.0",
"port": 3000,
"baseUrl": "https://example.org",
"storage": { "type": "fs", "root": "./data" }
});
let nested = normalise_file_shape(flat);
assert_eq!(nested["server"]["host"], "0.0.0.0");
assert_eq!(nested["server"]["port"], 3000);
assert_eq!(nested["server"]["base_url"], "https://example.org");
assert_eq!(nested["storage"]["type"], "fs");
}
#[test]
fn nested_file_shape_passes_through() {
let nested = serde_json::json!({
"server": { "host": "0.0.0.0", "port": 3000 }
});
let out = normalise_file_shape(nested.clone());
assert_eq!(out, nested);
}
}