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