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