Skip to main content

ma_core/config/
mod.rs

1//! Configuration for ma-core-based daemons.
2//!
3//! Provides [`Config`], a configuration model that supports:
4//!
5//! - native daemon bootstrapping from CLI/env/YAML/defaults via
6//!   [`Config::from_args`]
7//! - storage-agnostic serialization workflows (including wasm) via
8//!   [`Config::from_yaml_str`] and [`Config::to_yaml_string`]
9//!
10//! Native `from_args` resolves fields from (in decreasing priority):
11//!
12//! 1. Explicit CLI arguments (via [`MaArgs`])
13//! 2. `MA_*` environment variables
14//! 3. YAML config file (`XDG_CONFIG_HOME/ma/<slug>.yaml`)
15//! 4. Built-in defaults
16//!
17//! # Native compile-time constant requirement
18//!
19//! Binaries using [`Config::from_args`] **must** declare a compile-time
20//! constant:
21//!
22//! ```no_run
23//! const MA_DEFAULT_SLUG: &str = "panteia";
24//! ```
25//!
26//! This constant serves a dual purpose:
27//! - **Default slug** — used for file naming when `--slug` is not set.
28//! - **Env-var prefix** — uppercased to `MA_PANTEIA_*` for env-var lookup.
29//!   This prefix is fixed at compile time and cannot be changed at runtime.
30//!   Only file-naming can be overridden via `--slug`.
31
32#[cfg(not(target_arch = "wasm32"))]
33pub mod cli;
34#[cfg(not(target_arch = "wasm32"))]
35mod logging;
36#[cfg(target_arch = "wasm32")]
37mod logging_wasm;
38pub mod secrets;
39
40#[cfg(not(target_arch = "wasm32"))]
41pub use cli::MaArgs;
42pub use secrets::SecretBundle;
43
44#[cfg(target_arch = "wasm32")]
45use std::path::PathBuf;
46#[cfg(not(target_arch = "wasm32"))]
47use std::path::{Path, PathBuf};
48
49use crate::error::{Error, Result};
50use base64::engine::general_purpose::STANDARD as B64;
51use base64::Engine;
52use serde::{Deserialize, Serialize};
53
54// ─── Defaults ────────────────────────────────────────────────────────────────
55
56const DEFAULT_LOG_LEVEL: &str = "info";
57const DEFAULT_LOG_LEVEL_STDOUT: &str = "info";
58const DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS: u64 = 60;
59const DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS: u64 = 10;
60#[cfg(not(target_arch = "wasm32"))]
61const DEFAULT_KUBO_RPC_URL: &str = "http://127.0.0.1:5001";
62
63// ─── Remote pinning ─────────────────────────────────────────────────────────
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct RemotePinConfig {
67    pub service: String,
68    pub name: String,
69}
70
71// ─── Config struct ───────────────────────────────────────────────────────────
72
73/// Runtime configuration for a ma daemon.
74///
75/// Build via [`Config::from_args`] on native targets or via YAML/string
76/// serialization helpers on wasm.
77#[derive(Debug, Clone)]
78pub struct Config {
79    /// Short printable slug identifying this daemon instance.
80    /// Used in default file names: `<slug>.yaml`, `<slug>.bin`, `<slug>.log`.
81    pub slug: String,
82
83    /// Log level written to the log file (e.g. `"info"`, `"debug"`).
84    pub log_level: String,
85
86    /// Log level written to stdout.
87    pub log_level_stdout: String,
88
89    /// Cache TTL (seconds) for successful DID document resolutions.
90    /// Set to `0` to disable positive cache entries.
91    pub did_resolver_positive_ttl_secs: u64,
92
93    /// Cache TTL (seconds) for failed DID document resolutions.
94    /// Set to `0` to disable negative cache entries.
95    pub did_resolver_negative_ttl_secs: u64,
96
97    /// Path to the log file. `None` → resolved to `XDG_DATA_HOME/ma/<slug>.log`
98    /// on first use.
99    pub log_file: Option<PathBuf>,
100
101    #[cfg(not(target_arch = "wasm32"))]
102    /// Kubo JSON-RPC API URL.
103    pub kubo_rpc_url: String,
104
105    #[cfg(not(target_arch = "wasm32"))]
106    /// IPNS key alias registered in Kubo for this daemon.
107    pub kubo_key_alias: String,
108
109    /// Path to the encrypted secret bundle. `None` → `XDG_CONFIG_HOME/ma/<slug>.bin`.
110    pub secret_bundle: Option<PathBuf>,
111
112    /// Passphrase to unlock the secret bundle.
113    /// In headless configs this is stored in cleartext in the YAML file.
114    pub secret_bundle_passphrase: Option<String>,
115
116    /// Path where this config was loaded from or will be saved to.
117    pub config_path: Option<PathBuf>,
118
119    /// Whether to mirror selected CIDs to a configured Kubo remote pinning service.
120    pub pin_remote: bool,
121
122    /// Kubo remote pinning service name, e.g. `pinata`.
123    pub pin_remote_service: Option<String>,
124
125    /// Operator-visible remote pin name. Callers supply a default when unset.
126    pub pin_remote_name: Option<String>,
127
128    /// Extra user-defined YAML keys that are not part of the core schema.
129    /// Preserved during load and save so callers can extend the config freely.
130    pub extra: serde_yaml::Mapping,
131}
132
133/// Browser-friendly identity export payload.
134///
135/// Contains serialized config text and an encrypted secret bundle encoded as
136/// base64 so it can be stored or copied as plain JSON.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct BrowserIdentityExport {
139    pub version: u8,
140    pub config_yaml: String,
141    pub encrypted_secret_bundle_base64: String,
142}
143
144impl BrowserIdentityExport {
145    pub fn new(config_yaml: String, encrypted_secret_bundle: &[u8]) -> Self {
146        Self {
147            version: 1,
148            config_yaml,
149            encrypted_secret_bundle_base64: B64.encode(encrypted_secret_bundle),
150        }
151    }
152
153    pub fn encrypted_secret_bundle_bytes(&self) -> Result<Vec<u8>> {
154        B64.decode(self.encrypted_secret_bundle_base64.as_bytes())
155            .map_err(|e| Error::Config(format!("invalid encrypted bundle base64: {e}")))
156    }
157
158    pub fn to_json_string(&self) -> Result<String> {
159        serde_json::to_string(self)
160            .map_err(|e| Error::Config(format!("failed to serialize browser export: {e}")))
161    }
162
163    pub fn from_json_str(json: &str) -> Result<Self> {
164        serde_json::from_str(json)
165            .map_err(|e| Error::Config(format!("failed to parse browser export JSON: {e}")))
166    }
167}
168
169// ─── XDG path helpers ────────────────────────────────────────────────────────
170
171#[cfg(not(target_arch = "wasm32"))]
172fn project_dirs() -> Result<directories::ProjectDirs> {
173    directories::ProjectDirs::from("", "ma", "ma")
174        .ok_or_else(|| Error::Config("cannot determine XDG base directories".to_string()))
175}
176
177/// Default YAML config path: `XDG_CONFIG_HOME/ma/<slug>.yaml`.
178#[cfg(not(target_arch = "wasm32"))]
179pub fn default_config_path(slug: &str) -> Result<PathBuf> {
180    Ok(project_dirs()?.config_dir().join(format!("{slug}.yaml")))
181}
182
183/// Default secret bundle path: `XDG_CONFIG_HOME/ma/<slug>.bin`.
184#[cfg(not(target_arch = "wasm32"))]
185pub fn default_secret_bundle_path(slug: &str) -> Result<PathBuf> {
186    Ok(project_dirs()?.config_dir().join(format!("{slug}.bin")))
187}
188
189/// Default log file path: `XDG_DATA_HOME/ma/<slug>.log`.
190#[cfg(not(target_arch = "wasm32"))]
191pub fn default_log_file_path(slug: &str) -> Result<PathBuf> {
192    Ok(project_dirs()?.data_dir().join(format!("{slug}.log")))
193}
194
195// ─── Secure file I/O ─────────────────────────────────────────────────────────
196
197/// Write `data` to `path`, creating parent directories as needed.
198///
199/// On Unix the file is created (or truncated) with mode `0600`. On other
200/// platforms the file is written without special permission handling.
201#[cfg(not(target_arch = "wasm32"))]
202pub(crate) fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
203    use std::io::Write;
204
205    if let Some(parent) = path.parent() {
206        std::fs::create_dir_all(parent).map_err(|e| {
207            Error::Config(format!("failed to create dir {}: {e}", parent.display()))
208        })?;
209    }
210
211    #[cfg(unix)]
212    let mut file = {
213        use std::os::unix::fs::OpenOptionsExt;
214        std::fs::OpenOptions::new()
215            .write(true)
216            .create(true)
217            .truncate(true)
218            .mode(0o600)
219            .open(path)
220            .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?
221    };
222
223    #[cfg(not(unix))]
224    let mut file = std::fs::OpenOptions::new()
225        .write(true)
226        .create(true)
227        .truncate(true)
228        .open(path)
229        .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?;
230
231    file.write_all(data)
232        .map_err(|e| Error::Config(format!("failed to write {}: {e}", path.display())))?;
233
234    // Belt-and-suspenders: also set permissions after creation (handles the
235    // case where the file already existed with wider permissions).
236    #[cfg(unix)]
237    {
238        use std::os::unix::fs::PermissionsExt;
239        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
240            Error::Config(format!(
241                "failed to set permissions on {}: {e}",
242                path.display()
243            ))
244        })?;
245    }
246
247    Ok(())
248}
249
250/// Check that a file's permissions are not wider than `0600` and log a
251/// warning if they are. Only active on Unix.
252#[cfg(all(not(target_arch = "wasm32"), unix))]
253fn check_permissions(path: &Path) {
254    use std::os::unix::fs::MetadataExt;
255    if let Ok(meta) = std::fs::metadata(path) {
256        let mode = meta.mode() & 0o777;
257        if mode > 0o600 {
258            tracing::warn!(
259                path = %path.display(),
260                mode = format!("{mode:04o}"),
261                "config file has permissions wider than 0600 — consider `chmod 0600 {}`",
262                path.display()
263            );
264        }
265    }
266}
267
268#[cfg(all(not(target_arch = "wasm32"), not(unix)))]
269fn check_permissions(_path: &Path) {}
270
271// ─── YAML helpers ────────────────────────────────────────────────────────────
272
273#[cfg(not(target_arch = "wasm32"))]
274fn load_yaml_mapping(path: &Path) -> Result<serde_yaml::Mapping> {
275    let content = std::fs::read_to_string(path)
276        .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
277    let val: serde_yaml::Value = serde_yaml::from_str(&content)
278        .map_err(|e| Error::Config(format!("invalid YAML in {}: {e}", path.display())))?;
279    if let serde_yaml::Value::Mapping(m) = val {
280        Ok(m)
281    } else {
282        Err(Error::Config(format!(
283            "config file {} must be a YAML mapping",
284            path.display()
285        )))
286    }
287}
288
289#[cfg(not(target_arch = "wasm32"))]
290fn yaml_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
291    m.get(serde_yaml::Value::String(key.to_string()))
292        .and_then(|v| v.as_str())
293        .map(String::from)
294}
295
296#[cfg(not(target_arch = "wasm32"))]
297fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
298    m.get(serde_yaml::Value::String(key.to_string()))
299        .and_then(|v| v.as_str())
300        .map(PathBuf::from)
301}
302
303#[cfg(not(target_arch = "wasm32"))]
304fn yaml_u64(m: &serde_yaml::Mapping, key: &str) -> Option<u64> {
305    m.get(serde_yaml::Value::String(key.to_string()))
306        .and_then(|v| match v {
307            serde_yaml::Value::Number(n) => n.as_u64(),
308            serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
309            _ => None,
310        })
311}
312
313#[cfg(not(target_arch = "wasm32"))]
314fn yaml_bool(m: &serde_yaml::Mapping, key: &str) -> Option<bool> {
315    m.get(serde_yaml::Value::String(key.to_string()))
316        .and_then(|v| match v {
317            serde_yaml::Value::Bool(b) => Some(*b),
318            serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
319            _ => None,
320        })
321}
322
323// ─── Config impl ─────────────────────────────────────────────────────────────
324
325impl Config {
326    /// Construct a config value suitable for wasm/local storage workflows.
327    ///
328    /// This constructor is storage-agnostic and does not touch the filesystem.
329    pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
330        let slug = slug.as_ref().to_string();
331        Self {
332            slug: slug.clone(),
333            log_level: DEFAULT_LOG_LEVEL.to_string(),
334            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
335            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
336            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
337            log_file: None,
338            #[cfg(not(target_arch = "wasm32"))]
339            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
340            #[cfg(not(target_arch = "wasm32"))]
341            kubo_key_alias: slug,
342            secret_bundle: None,
343            secret_bundle_passphrase: None,
344            config_path: None,
345            pin_remote: false,
346            pin_remote_service: None,
347            pin_remote_name: None,
348            extra: serde_yaml::Mapping::new(),
349        }
350    }
351
352    /// Deserialize a config value from YAML text without filesystem I/O.
353    pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
354        let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
355            .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
356        let mut m = match val {
357            serde_yaml::Value::Mapping(m) => m,
358            _ => {
359                return Err(Error::Config(
360                    "config YAML must be a mapping at the top level".to_string(),
361                ));
362            }
363        };
364
365        let take_str = |map: &mut serde_yaml::Mapping, key: &str| {
366            map.remove(serde_yaml::Value::String(key.to_string()))
367                .and_then(|v| v.as_str().map(ToOwned::to_owned))
368        };
369
370        let take_path = |map: &mut serde_yaml::Mapping, key: &str| {
371            map.remove(serde_yaml::Value::String(key.to_string()))
372                .and_then(|v| v.as_str().map(PathBuf::from))
373        };
374
375        let take_u64 = |map: &mut serde_yaml::Mapping, key: &str| {
376            map.remove(serde_yaml::Value::String(key.to_string()))
377                .and_then(|v| match v {
378                    serde_yaml::Value::Number(n) => n.as_u64(),
379                    serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
380                    _ => None,
381                })
382        };
383
384        let take_bool = |map: &mut serde_yaml::Mapping, key: &str| {
385            map.remove(serde_yaml::Value::String(key.to_string()))
386                .and_then(|v| match v {
387                    serde_yaml::Value::Bool(b) => Some(b),
388                    serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
389                    _ => None,
390                })
391        };
392
393        let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
394        let log_level =
395            take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
396        let log_level_stdout = take_str(&mut m, "log_level_stdout")
397            .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
398        let did_resolver_positive_ttl_secs = take_u64(&mut m, "did_resolver_positive_ttl_secs")
399            .unwrap_or(DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS);
400        let did_resolver_negative_ttl_secs = take_u64(&mut m, "did_resolver_negative_ttl_secs")
401            .unwrap_or(DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS);
402        // `config_path` is runtime state and should never be restored from YAML.
403        let _ignored_config_path = take_path(&mut m, "config_path");
404        #[cfg(not(target_arch = "wasm32"))]
405        let kubo_rpc_url =
406            take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
407        #[cfg(not(target_arch = "wasm32"))]
408        let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
409
410        Ok(Self {
411            slug,
412            log_level,
413            log_level_stdout,
414            did_resolver_positive_ttl_secs,
415            did_resolver_negative_ttl_secs,
416            log_file: take_path(&mut m, "log_file"),
417            #[cfg(not(target_arch = "wasm32"))]
418            kubo_rpc_url,
419            #[cfg(not(target_arch = "wasm32"))]
420            kubo_key_alias,
421            secret_bundle: take_path(&mut m, "secret_bundle"),
422            secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
423            config_path: None,
424            pin_remote: take_bool(&mut m, "pin_remote").unwrap_or(false),
425            pin_remote_service: take_str(&mut m, "pin_remote_service"),
426            pin_remote_name: take_str(&mut m, "pin_remote_name"),
427            extra: m,
428        })
429    }
430
431    /// Serialize config to YAML text without filesystem I/O.
432    pub fn to_yaml_string(&self) -> Result<String> {
433        let mut m = self.extra.clone();
434
435        let mut set = |k: &str, v: serde_yaml::Value| {
436            m.insert(serde_yaml::Value::String(k.to_string()), v);
437        };
438
439        // NOTE: `slug` is intentionally omitted — it selects which config file
440        // to open, so storing it inside that file is a catch-22.
441        // It is read only from CLI (--slug) or env (MA_SLUG), never from YAML.
442        set(
443            "log_level",
444            serde_yaml::Value::String(self.log_level.clone()),
445        );
446        set(
447            "log_level_stdout",
448            serde_yaml::Value::String(self.log_level_stdout.clone()),
449        );
450        set(
451            "did_resolver_positive_ttl_secs",
452            serde_yaml::Value::Number(serde_yaml::Number::from(
453                self.did_resolver_positive_ttl_secs,
454            )),
455        );
456        set(
457            "did_resolver_negative_ttl_secs",
458            serde_yaml::Value::Number(serde_yaml::Number::from(
459                self.did_resolver_negative_ttl_secs,
460            )),
461        );
462        #[cfg(not(target_arch = "wasm32"))]
463        set(
464            "kubo_rpc_url",
465            serde_yaml::Value::String(self.kubo_rpc_url.clone()),
466        );
467        #[cfg(not(target_arch = "wasm32"))]
468        set(
469            "kubo_key_alias",
470            serde_yaml::Value::String(self.kubo_key_alias.clone()),
471        );
472
473        if let Some(ref p) = self.log_file {
474            set(
475                "log_file",
476                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
477            );
478        }
479        if let Some(ref p) = self.secret_bundle {
480            set(
481                "secret_bundle",
482                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
483            );
484        }
485        if let Some(ref pw) = self.secret_bundle_passphrase {
486            set(
487                "secret_bundle_passphrase",
488                serde_yaml::Value::String(pw.clone()),
489            );
490        }
491        set("pin_remote", serde_yaml::Value::Bool(self.pin_remote));
492        if let Some(ref service) = self.pin_remote_service {
493            set(
494                "pin_remote_service",
495                serde_yaml::Value::String(service.clone()),
496            );
497        }
498        if let Some(ref name) = self.pin_remote_name {
499            set("pin_remote_name", serde_yaml::Value::String(name.clone()));
500        }
501
502        serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
503            .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
504    }
505
506    /// Serialize config to YAML text while excluding secret passphrase fields.
507    ///
508    /// Useful for browser storage where passphrases should be provided by
509    /// runtime user input instead of persisted state.
510    pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
511        let mut copy = self.clone();
512        copy.secret_bundle_passphrase = None;
513        copy.to_yaml_string()
514    }
515
516    #[cfg(not(target_arch = "wasm32"))]
517    /// Build a `Config` by merging CLI arguments, environment variables, a
518    /// YAML config file, and built-in defaults.
519    ///
520    /// # Required compile-time constant
521    ///
522    /// Callers **MUST** pass a compile-time constant `MA_DEFAULT_SLUG: &'static str`.
523    /// This determines BOTH the default slug for file naming AND the fixed
524    /// env-var prefix. Only file naming may be overridden via `--slug`.
525    ///
526    /// ```
527    /// # #[cfg(all(feature = "config", not(target_arch = "wasm32")))]
528    /// # {
529    /// use ma_core::config::{Config, MaArgs};
530    /// let args = MaArgs::default();
531    /// let config = Config::from_args(&args, "doctest")?;
532    /// assert_eq!(config.slug, "doctest");
533    /// # }
534    /// # Ok::<(), ma_core::Error>(())
535    /// ```
536    ///
537    /// # Priority
538    ///
539    /// For each field the resolution order is:
540    /// 1. Explicit CLI argument
541    /// 2. `MA_FIELD` environment variable
542    /// 3. Value from the YAML config file
543    /// 4. Built-in default
544    #[allow(clippy::too_many_lines)]
545    pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
546        // Slug: CLI/env via clap (MA_SLUG) → compile-time default.
547        let slug = args
548            .slug
549            .clone()
550            .unwrap_or_else(|| default_slug.to_string());
551
552        // Config file path: explicit → slug-based XDG default.
553        let config_path = if let Some(ref p) = args.config {
554            p.clone()
555        } else {
556            default_config_path(&slug)?
557        };
558
559        // Load YAML if the file exists.
560        let yaml = if config_path.exists() {
561            check_permissions(&config_path);
562            Some(load_yaml_mapping(&config_path)?)
563        } else {
564            None
565        };
566
567        // Helper: resolve a string field through the priority chain.
568        // NOTE: closures borrow `yaml` and `prefix` immutably; NLL ensures
569        // the borrows end before we move `yaml` below.
570        let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
571            cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
572                .or_else(|| {
573                    yaml.as_ref()
574                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
575                })
576                .unwrap_or_else(|| default.to_string())
577        };
578
579        let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
580            cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
581                .or_else(|| {
582                    yaml.as_ref()
583                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
584                })
585        };
586
587        let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
588            cli.or_else(|| {
589                std::env::var(format!("MA_{env_key}"))
590                    .ok()
591                    .map(PathBuf::from)
592            })
593            .or_else(|| {
594                yaml.as_ref()
595                    .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
596            })
597        };
598
599        let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
600            cli.or_else(|| {
601                std::env::var(format!("MA_{env_key}"))
602                    .ok()
603                    .and_then(|v| v.parse::<u64>().ok())
604            })
605            .or_else(|| {
606                yaml.as_ref()
607                    .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
608            })
609            .unwrap_or(default)
610        };
611
612        let resolve_bool = |cli: Option<bool>, env_key: &str, default: bool| -> bool {
613            cli.or_else(|| {
614                std::env::var(format!("MA_{env_key}"))
615                    .ok()
616                    .and_then(|v| v.parse::<bool>().ok())
617            })
618            .or_else(|| {
619                yaml.as_ref()
620                    .and_then(|m| yaml_bool(m, &env_key.to_lowercase()))
621            })
622            .unwrap_or(default)
623        };
624
625        let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
626        let log_level_stdout = resolve_str(
627            args.log_level_stdout.clone(),
628            "LOG_LEVEL_STDOUT",
629            DEFAULT_LOG_LEVEL_STDOUT,
630        );
631        let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
632        let did_resolver_positive_ttl_secs = resolve_u64(
633            args.did_resolver_positive_ttl_secs,
634            "DID_RESOLVER_POSITIVE_TTL_SECS",
635            DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
636        );
637        let did_resolver_negative_ttl_secs = resolve_u64(
638            args.did_resolver_negative_ttl_secs,
639            "DID_RESOLVER_NEGATIVE_TTL_SECS",
640            DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
641        );
642        let kubo_rpc_url = resolve_str(
643            args.kubo_rpc_url.clone(),
644            "KUBO_RPC_URL",
645            DEFAULT_KUBO_RPC_URL,
646        );
647        let kubo_key_alias =
648            resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
649        let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
650        let secret_bundle_passphrase = resolve_opt_str(
651            args.secret_bundle_passphrase.clone(),
652            "SECRET_BUNDLE_PASSPHRASE",
653        );
654        let pin_remote = resolve_bool(args.pin_remote, "PIN_REMOTE", false);
655        let pin_remote_service =
656            resolve_opt_str(args.pin_remote_service.clone(), "PIN_REMOTE_SERVICE");
657        let pin_remote_name = resolve_opt_str(args.pin_remote_name.clone(), "PIN_REMOTE_NAME");
658
659        // Extra: all YAML keys that are not part of the core schema.
660        let known: &[&str] = &[
661            "slug",
662            "log_level",
663            "log_level_stdout",
664            "log_file",
665            "did_resolver_positive_ttl_secs",
666            "did_resolver_negative_ttl_secs",
667            "kubo_rpc_url",
668            "kubo_key_alias",
669            "secret_bundle",
670            "secret_bundle_passphrase",
671            "pin_remote",
672            "pin_remote_service",
673            "pin_remote_name",
674            // Legacy key; ignored and never persisted.
675            "config_path",
676        ];
677        let extra = yaml
678            .map(|mut m| {
679                for k in known {
680                    m.remove(serde_yaml::Value::String((*k).to_string()));
681                }
682                m
683            })
684            .unwrap_or_default();
685
686        Ok(Config {
687            slug,
688            log_level,
689            log_level_stdout,
690            did_resolver_positive_ttl_secs,
691            did_resolver_negative_ttl_secs,
692            log_file,
693            #[cfg(not(target_arch = "wasm32"))]
694            kubo_rpc_url,
695            #[cfg(not(target_arch = "wasm32"))]
696            kubo_key_alias,
697            secret_bundle,
698            secret_bundle_passphrase,
699            config_path: Some(config_path),
700            pin_remote,
701            pin_remote_service,
702            pin_remote_name,
703            extra,
704        })
705    }
706
707    /// Return validated remote pinning settings using `default_name` when the
708    /// config does not specify `pin_remote_name`.
709    pub fn remote_pin_config_with_default_name(
710        &self,
711        default_name: impl Into<String>,
712    ) -> Result<Option<RemotePinConfig>> {
713        if !self.pin_remote {
714            return Ok(None);
715        }
716        let service = self
717            .pin_remote_service
718            .as_deref()
719            .map(str::trim)
720            .filter(|value| !value.is_empty())
721            .ok_or_else(|| Error::Config("pin_remote requires pin_remote_service".to_string()))?;
722        let default_name = default_name.into();
723        let name = self
724            .pin_remote_name
725            .as_deref()
726            .map(str::trim)
727            .filter(|value| !value.is_empty())
728            .unwrap_or(default_name.as_str());
729        Ok(Some(RemotePinConfig {
730            service: service.to_string(),
731            name: name.to_string(),
732        }))
733    }
734
735    /// The effective log file path: `self.log_file` if set, otherwise the
736    /// XDG default `XDG_DATA_HOME/ma/<slug>.log`.
737    #[cfg(not(target_arch = "wasm32"))]
738    pub fn effective_log_file(&self) -> Result<PathBuf> {
739        if let Some(ref p) = self.log_file {
740            Ok(p.clone())
741        } else {
742            default_log_file_path(&self.slug)
743        }
744    }
745
746    /// The effective secret bundle path: `self.secret_bundle` if set,
747    /// otherwise the XDG default `XDG_CONFIG_HOME/ma/<slug>.bin`.
748    #[cfg(not(target_arch = "wasm32"))]
749    pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
750        if let Some(ref p) = self.secret_bundle {
751            Ok(p.clone())
752        } else {
753            default_secret_bundle_path(&self.slug)
754        }
755    }
756
757    /// Build a gateway-backed DID resolver using config TTL settings.
758    ///
759    /// Uses the built-in gateway list (localhost:8080 + public fallbacks).
760    /// Works on both native and WASM targets.
761    #[must_use]
762    pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
763        crate::ipfs::IpfsGatewayResolver::default().with_cache_ttls(
764            web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
765            web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
766        )
767    }
768
769    /// Save this config to [`Self::config_path`] as YAML with 0600
770    /// permissions. Returns an error if `config_path` is not set.
771    ///
772    /// Known fields are serialized explicitly; extra fields are merged in
773    /// afterwards so user-defined keys are preserved.
774    #[cfg(not(target_arch = "wasm32"))]
775    pub fn save(&self) -> Result<()> {
776        let path = self
777            .config_path
778            .as_ref()
779            .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
780
781        let yaml_text = self.to_yaml_string()?;
782
783        write_secure(path, yaml_text.as_bytes())
784    }
785
786    /// Generate a complete headless config:
787    ///
788    /// 1. Generate a fresh [`SecretBundle`] with four random 32-byte keys.
789    /// 2. Encrypt the bundle (using `args.secret_bundle_passphrase` or a
790    ///    freshly generated random passphrase).
791    /// 3. Write the encrypted bundle to `XDG_CONFIG_HOME/ma/<slug>.bin`
792    ///    (or the path from `--secret-bundle`) with mode 0600.
793    /// 4. Write the YAML config to `XDG_CONFIG_HOME/ma/<slug>.yaml`
794    ///    (or the path from `--config`) with the passphrase in cleartext and
795    ///    mode 0600.
796    /// 5. Print the paths of both files to stdout.
797    ///
798    /// Returns an error if either file already exists.
799    #[cfg(not(target_arch = "wasm32"))]
800    pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
801        let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
802
803        let config_path = if let Some(ref p) = args.config {
804            p.clone()
805        } else {
806            default_config_path(&slug)?
807        };
808        let bundle_path = if let Some(ref p) = args.secret_bundle {
809            p.clone()
810        } else {
811            default_secret_bundle_path(&slug)?
812        };
813
814        if config_path.exists() {
815            return Err(Error::Config(format!(
816                "config file already exists: {} (remove it first or use --config)",
817                config_path.display()
818            )));
819        }
820        if bundle_path.exists() {
821            return Err(Error::Config(format!(
822                "secret bundle already exists: {} (remove it first or use --secret-bundle)",
823                bundle_path.display()
824            )));
825        }
826
827        // Generate or use provided passphrase.
828        let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
829            p.clone()
830        } else {
831            SecretBundle::generate_passphrase()
832        };
833
834        // Generate and save the bundle.
835        let bundle = SecretBundle::generate();
836        bundle.save(&bundle_path, &passphrase)?;
837
838        // Build and save the config.
839        let config = Config {
840            slug: slug.clone(),
841            log_level: DEFAULT_LOG_LEVEL.to_string(),
842            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
843            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
844            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
845            log_file: None,
846            #[cfg(not(target_arch = "wasm32"))]
847            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
848            #[cfg(not(target_arch = "wasm32"))]
849            kubo_key_alias: slug.clone(),
850            secret_bundle: Some(bundle_path.clone()),
851            secret_bundle_passphrase: Some(passphrase),
852            config_path: Some(config_path.clone()),
853            pin_remote: false,
854            pin_remote_service: None,
855            pin_remote_name: None,
856            extra: serde_yaml::Mapping::new(),
857        };
858        config.save()?;
859
860        println!("Config:        {}", config_path.display());
861        println!("Secret bundle: {}", bundle_path.display());
862
863        Ok(())
864    }
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    #[test]
872    fn remote_pin_config_uses_caller_default_name() {
873        let config = Config::from_yaml_str(
874            r"
875pin_remote: true
876pin_remote_service: pinata
877",
878        )
879        .unwrap();
880
881        let remote = config
882            .remote_pin_config_with_default_name("ma-runtime-ma-root")
883            .unwrap()
884            .unwrap();
885        assert_eq!(remote.service, "pinata");
886        assert_eq!(remote.name, "ma-runtime-ma-root");
887    }
888
889    #[test]
890    fn remote_pin_config_requires_service_when_enabled() {
891        let config = Config::from_yaml_str("pin_remote: true\n").unwrap();
892
893        let err = config
894            .remote_pin_config_with_default_name("ma-runtime-ma-root")
895            .unwrap_err();
896        assert!(err.to_string().contains("pin_remote_service"));
897    }
898
899    #[cfg(not(target_arch = "wasm32"))]
900    #[test]
901    fn cli_pin_remote_overrides_yaml_default() {
902        let config = Config::from_args(
903            &MaArgs {
904                pin_remote: Some(true),
905                pin_remote_service: Some("pinata".to_string()),
906                pin_remote_name: Some("custom-root".to_string()),
907                ..MaArgs::default()
908            },
909            "test",
910        )
911        .unwrap();
912
913        assert!(config.pin_remote);
914        assert_eq!(config.pin_remote_service.as_deref(), Some("pinata"));
915        assert_eq!(config.pin_remote_name.as_deref(), Some("custom-root"));
916    }
917}