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_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
300    m.get(serde_yaml::Value::String(key.to_string()))
301        .and_then(|v| v.as_str())
302        .map(String::from)
303}
304
305#[cfg(not(target_arch = "wasm32"))]
306fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
307    m.get(serde_yaml::Value::String(key.to_string()))
308        .and_then(|v| v.as_str())
309        .map(PathBuf::from)
310}
311
312#[cfg(not(target_arch = "wasm32"))]
313fn yaml_u64(m: &serde_yaml::Mapping, key: &str) -> Option<u64> {
314    m.get(serde_yaml::Value::String(key.to_string()))
315        .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    m.get(serde_yaml::Value::String(key.to_string()))
325        .and_then(|v| match v {
326            serde_yaml::Value::Bool(b) => Some(*b),
327            serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
328            _ => None,
329        })
330}
331
332// ─── Config impl ─────────────────────────────────────────────────────────────
333
334impl Config {
335    /// Construct a config value suitable for wasm/local storage workflows.
336    ///
337    /// This constructor is storage-agnostic and does not touch the filesystem.
338    pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
339        let slug = slug.as_ref().to_string();
340        Self {
341            slug: slug.clone(),
342            log_level: DEFAULT_LOG_LEVEL.to_string(),
343            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
344            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
345            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
346            log_file: None,
347            #[cfg(not(target_arch = "wasm32"))]
348            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
349            #[cfg(not(target_arch = "wasm32"))]
350            kubo_key_alias: slug,
351            secret_bundle: None,
352            secret_bundle_passphrase: None,
353            config_path: None,
354            pin_remote: false,
355            pin_remote_service: None,
356            pin_remote_name: None,
357            pin_overwrite: true,
358            extra: serde_yaml::Mapping::new(),
359        }
360    }
361
362    /// Deserialize a config value from YAML text without filesystem I/O.
363    pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
364        let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
365            .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
366        let mut m = match val {
367            serde_yaml::Value::Mapping(m) => m,
368            _ => {
369                return Err(Error::Config(
370                    "config YAML must be a mapping at the top level".to_string(),
371                ));
372            }
373        };
374
375        let take_str = |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(ToOwned::to_owned))
378        };
379
380        let take_path = |map: &mut serde_yaml::Mapping, key: &str| {
381            map.remove(serde_yaml::Value::String(key.to_string()))
382                .and_then(|v| v.as_str().map(PathBuf::from))
383        };
384
385        let take_u64 = |map: &mut serde_yaml::Mapping, key: &str| {
386            map.remove(serde_yaml::Value::String(key.to_string()))
387                .and_then(|v| match v {
388                    serde_yaml::Value::Number(n) => n.as_u64(),
389                    serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
390                    _ => None,
391                })
392        };
393
394        let take_bool = |map: &mut serde_yaml::Mapping, key: &str| {
395            map.remove(serde_yaml::Value::String(key.to_string()))
396                .and_then(|v| match v {
397                    serde_yaml::Value::Bool(b) => Some(b),
398                    serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
399                    _ => None,
400                })
401        };
402
403        let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
404        let log_level =
405            take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
406        let log_level_stdout = take_str(&mut m, "log_level_stdout")
407            .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
408        let did_resolver_positive_ttl_secs = take_u64(&mut m, "did_resolver_positive_ttl_secs")
409            .unwrap_or(DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS);
410        let did_resolver_negative_ttl_secs = take_u64(&mut m, "did_resolver_negative_ttl_secs")
411            .unwrap_or(DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS);
412        // `config_path` is runtime state and should never be restored from YAML.
413        let _ignored_config_path = take_path(&mut m, "config_path");
414        // Legacy key; consumed so it does not linger in `extra`.
415        let _ignored_old_pin_batch_size = take_u64(&mut m, "old_pin_batch_size");
416        #[cfg(not(target_arch = "wasm32"))]
417        let kubo_rpc_url =
418            take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
419        #[cfg(not(target_arch = "wasm32"))]
420        let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
421
422        Ok(Self {
423            slug,
424            log_level,
425            log_level_stdout,
426            did_resolver_positive_ttl_secs,
427            did_resolver_negative_ttl_secs,
428            log_file: take_path(&mut m, "log_file"),
429            #[cfg(not(target_arch = "wasm32"))]
430            kubo_rpc_url,
431            #[cfg(not(target_arch = "wasm32"))]
432            kubo_key_alias,
433            secret_bundle: take_path(&mut m, "secret_bundle"),
434            secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
435            config_path: None,
436            pin_remote: take_bool(&mut m, "pin_remote").unwrap_or(false),
437            pin_remote_service: take_str(&mut m, "pin_remote_service"),
438            pin_remote_name: take_str(&mut m, "pin_remote_name"),
439            pin_overwrite: take_bool(&mut m, "pin_overwrite").unwrap_or(true),
440            extra: m,
441        })
442    }
443
444    /// Serialize config to YAML text without filesystem I/O.
445    pub fn to_yaml_string(&self) -> Result<String> {
446        let mut m = self.extra.clone();
447
448        let mut set = |k: &str, v: serde_yaml::Value| {
449            m.insert(serde_yaml::Value::String(k.to_string()), v);
450        };
451
452        set("slug", serde_yaml::Value::String(self.slug.clone()));
453        set(
454            "log_level",
455            serde_yaml::Value::String(self.log_level.clone()),
456        );
457        set(
458            "log_level_stdout",
459            serde_yaml::Value::String(self.log_level_stdout.clone()),
460        );
461        set(
462            "did_resolver_positive_ttl_secs",
463            serde_yaml::Value::Number(serde_yaml::Number::from(
464                self.did_resolver_positive_ttl_secs,
465            )),
466        );
467        set(
468            "did_resolver_negative_ttl_secs",
469            serde_yaml::Value::Number(serde_yaml::Number::from(
470                self.did_resolver_negative_ttl_secs,
471            )),
472        );
473        #[cfg(not(target_arch = "wasm32"))]
474        set(
475            "kubo_rpc_url",
476            serde_yaml::Value::String(self.kubo_rpc_url.clone()),
477        );
478        #[cfg(not(target_arch = "wasm32"))]
479        set(
480            "kubo_key_alias",
481            serde_yaml::Value::String(self.kubo_key_alias.clone()),
482        );
483
484        if let Some(ref p) = self.log_file {
485            set(
486                "log_file",
487                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
488            );
489        }
490        if let Some(ref p) = self.secret_bundle {
491            set(
492                "secret_bundle",
493                serde_yaml::Value::String(p.to_string_lossy().into_owned()),
494            );
495        }
496        if let Some(ref pw) = self.secret_bundle_passphrase {
497            set(
498                "secret_bundle_passphrase",
499                serde_yaml::Value::String(pw.clone()),
500            );
501        }
502        set("pin_remote", serde_yaml::Value::Bool(self.pin_remote));
503        if let Some(ref service) = self.pin_remote_service {
504            set(
505                "pin_remote_service",
506                serde_yaml::Value::String(service.clone()),
507            );
508        }
509        if let Some(ref name) = self.pin_remote_name {
510            set("pin_remote_name", serde_yaml::Value::String(name.clone()));
511        }
512        set("pin_overwrite", serde_yaml::Value::Bool(self.pin_overwrite));
513
514        serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
515            .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
516    }
517
518    /// Serialize config to YAML text while excluding secret passphrase fields.
519    ///
520    /// Useful for browser storage where passphrases should be provided by
521    /// runtime user input instead of persisted state.
522    pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
523        let mut copy = self.clone();
524        copy.secret_bundle_passphrase = None;
525        copy.to_yaml_string()
526    }
527
528    #[cfg(not(target_arch = "wasm32"))]
529    /// Build a `Config` by merging CLI arguments, environment variables, a
530    /// YAML config file, and built-in defaults.
531    ///
532    /// # Required compile-time constant
533    ///
534    /// Callers **MUST** pass a compile-time constant `MA_DEFAULT_SLUG: &'static str`.
535    /// This determines BOTH the default slug for file naming AND the fixed
536    /// env-var prefix. Only file naming may be overridden via `--slug`.
537    ///
538    /// ```
539    /// # #[cfg(all(feature = "config", not(target_arch = "wasm32")))]
540    /// # {
541    /// use ma_core::config::{Config, MaArgs};
542    /// let args = MaArgs::default();
543    /// let config = Config::from_args(&args, "doctest")?;
544    /// assert_eq!(config.slug, "doctest");
545    /// # }
546    /// # Ok::<(), ma_core::Error>(())
547    /// ```
548    ///
549    /// # Priority
550    ///
551    /// For each field other than config-file selection, the resolution order is:
552    /// 1. Explicit CLI argument
553    /// 2. `MA_FIELD` environment variable
554    /// 3. Value from the YAML config file
555    /// 4. Built-in default
556    #[allow(clippy::too_many_lines)]
557    pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
558        // Select the config path before loading YAML. A slug within the loaded
559        // file may set the effective runtime slug, but cannot redirect this read.
560        let config_slug = args
561            .slug
562            .clone()
563            .unwrap_or_else(|| default_slug.to_string());
564
565        // Config file path: explicit → slug-based XDG default.
566        let config_path = if let Some(ref p) = args.config {
567            p.clone()
568        } else {
569            default_config_path(&config_slug)?
570        };
571
572        // Load YAML if the file exists.
573        let yaml = if config_path.exists() {
574            check_permissions(&config_path);
575            Some(load_yaml_mapping(&config_path)?)
576        } else {
577            None
578        };
579
580        let slug = args
581            .slug
582            .clone()
583            .or_else(|| yaml.as_ref().and_then(|m| yaml_str(m, "slug")))
584            .unwrap_or_else(|| default_slug.to_string());
585
586        // Helper: resolve a string field through the priority chain.
587        // NOTE: closures borrow `yaml` and `prefix` immutably; NLL ensures
588        // the borrows end before we move `yaml` below.
589        let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
590            cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
591                .or_else(|| {
592                    yaml.as_ref()
593                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
594                })
595                .unwrap_or_else(|| default.to_string())
596        };
597
598        let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
599            cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
600                .or_else(|| {
601                    yaml.as_ref()
602                        .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
603                })
604        };
605
606        let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
607            cli.or_else(|| {
608                std::env::var(format!("MA_{env_key}"))
609                    .ok()
610                    .map(PathBuf::from)
611            })
612            .or_else(|| {
613                yaml.as_ref()
614                    .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
615            })
616        };
617
618        let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
619            cli.or_else(|| {
620                std::env::var(format!("MA_{env_key}"))
621                    .ok()
622                    .and_then(|v| v.parse::<u64>().ok())
623            })
624            .or_else(|| {
625                yaml.as_ref()
626                    .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
627            })
628            .unwrap_or(default)
629        };
630
631        let resolve_bool = |cli: Option<bool>, env_key: &str, default: bool| -> bool {
632            cli.or_else(|| {
633                std::env::var(format!("MA_{env_key}"))
634                    .ok()
635                    .and_then(|v| v.parse::<bool>().ok())
636            })
637            .or_else(|| {
638                yaml.as_ref()
639                    .and_then(|m| yaml_bool(m, &env_key.to_lowercase()))
640            })
641            .unwrap_or(default)
642        };
643
644        let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
645        let log_level_stdout = resolve_str(
646            args.log_level_stdout.clone(),
647            "LOG_LEVEL_STDOUT",
648            DEFAULT_LOG_LEVEL_STDOUT,
649        );
650        let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
651        let did_resolver_positive_ttl_secs = resolve_u64(
652            args.did_resolver_positive_ttl_secs,
653            "DID_RESOLVER_POSITIVE_TTL_SECS",
654            DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
655        );
656        let did_resolver_negative_ttl_secs = resolve_u64(
657            args.did_resolver_negative_ttl_secs,
658            "DID_RESOLVER_NEGATIVE_TTL_SECS",
659            DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
660        );
661        let kubo_rpc_url = resolve_str(
662            args.kubo_rpc_url.clone(),
663            "KUBO_RPC_URL",
664            DEFAULT_KUBO_RPC_URL,
665        );
666        let kubo_key_alias =
667            resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
668        let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
669        let secret_bundle_passphrase = resolve_opt_str(
670            args.secret_bundle_passphrase.clone(),
671            "SECRET_BUNDLE_PASSPHRASE",
672        );
673        let pin_remote = resolve_bool(args.pin_remote, "PIN_REMOTE", false);
674        let pin_remote_service =
675            resolve_opt_str(args.pin_remote_service.clone(), "PIN_REMOTE_SERVICE");
676        let pin_remote_name = resolve_opt_str(args.pin_remote_name.clone(), "PIN_REMOTE_NAME");
677        let pin_overwrite = resolve_bool(args.pin_overwrite, "PIN_OVERWRITE", true);
678
679        // Extra: all YAML keys that are not part of the core schema.
680        let known: &[&str] = &[
681            "slug",
682            "log_level",
683            "log_level_stdout",
684            "log_file",
685            "did_resolver_positive_ttl_secs",
686            "did_resolver_negative_ttl_secs",
687            "kubo_rpc_url",
688            "kubo_key_alias",
689            "secret_bundle",
690            "secret_bundle_passphrase",
691            "pin_remote",
692            "pin_remote_service",
693            "pin_remote_name",
694            "pin_overwrite",
695            // Legacy key; ignored and never persisted.
696            "old_pin_batch_size",
697            // Legacy key; ignored and never persisted.
698            "config_path",
699        ];
700        let extra = yaml
701            .map(|mut m| {
702                for k in known {
703                    m.remove(serde_yaml::Value::String((*k).to_string()));
704                }
705                m
706            })
707            .unwrap_or_default();
708
709        Ok(Config {
710            slug,
711            log_level,
712            log_level_stdout,
713            did_resolver_positive_ttl_secs,
714            did_resolver_negative_ttl_secs,
715            log_file,
716            #[cfg(not(target_arch = "wasm32"))]
717            kubo_rpc_url,
718            #[cfg(not(target_arch = "wasm32"))]
719            kubo_key_alias,
720            secret_bundle,
721            secret_bundle_passphrase,
722            config_path: Some(config_path),
723            pin_remote,
724            pin_remote_service,
725            pin_remote_name,
726            pin_overwrite,
727            extra,
728        })
729    }
730
731    /// Return validated remote pinning settings using `default_name` when the
732    /// config does not specify `pin_remote_name`.
733    pub fn remote_pin_config_with_default_name(
734        &self,
735        default_name: impl Into<String>,
736    ) -> Result<Option<RemotePinConfig>> {
737        if !self.pin_remote {
738            return Ok(None);
739        }
740        let service = self
741            .pin_remote_service
742            .as_deref()
743            .map(str::trim)
744            .filter(|value| !value.is_empty())
745            .ok_or_else(|| Error::Config("pin_remote requires pin_remote_service".to_string()))?;
746        let default_name = default_name.into();
747        let name = self
748            .pin_remote_name
749            .as_deref()
750            .map(str::trim)
751            .filter(|value| !value.is_empty())
752            .unwrap_or(default_name.as_str());
753        Ok(Some(RemotePinConfig {
754            service: service.to_string(),
755            name: name.to_string(),
756            overwrite: self.pin_overwrite,
757        }))
758    }
759
760    /// The effective log file path: `self.log_file` if set, otherwise the
761    /// XDG default `XDG_DATA_HOME/ma/<slug>.log`.
762    #[cfg(not(target_arch = "wasm32"))]
763    pub fn effective_log_file(&self) -> Result<PathBuf> {
764        if let Some(ref p) = self.log_file {
765            Ok(p.clone())
766        } else {
767            default_log_file_path(&self.slug)
768        }
769    }
770
771    /// The effective secret bundle path: `self.secret_bundle` if set,
772    /// otherwise the XDG default `XDG_CONFIG_HOME/ma/<slug>.bin`.
773    #[cfg(not(target_arch = "wasm32"))]
774    pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
775        if let Some(ref p) = self.secret_bundle {
776            Ok(p.clone())
777        } else {
778            default_secret_bundle_path(&self.slug)
779        }
780    }
781
782    /// Build a gateway-backed DID resolver using config TTL settings.
783    ///
784    /// Uses the built-in gateway list (localhost:8080 + public fallbacks).
785    /// Works on both native and WASM targets.
786    #[must_use]
787    pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
788        crate::ipfs::IpfsGatewayResolver::default().with_cache_ttls(
789            web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
790            web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
791        )
792    }
793
794    /// Save this config to [`Self::config_path`] as YAML with 0600
795    /// permissions. Returns an error if `config_path` is not set.
796    ///
797    /// Known fields are serialized explicitly; extra fields are merged in
798    /// afterwards so user-defined keys are preserved.
799    #[cfg(not(target_arch = "wasm32"))]
800    pub fn save(&self) -> Result<()> {
801        let path = self
802            .config_path
803            .as_ref()
804            .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
805
806        let yaml_text = self.to_yaml_string()?;
807
808        write_secure(path, yaml_text.as_bytes())
809    }
810
811    /// Generate a complete headless config:
812    ///
813    /// 1. Generate a fresh [`SecretBundle`] with four random 32-byte keys.
814    /// 2. Encrypt the bundle (using `args.secret_bundle_passphrase` or a
815    ///    freshly generated random passphrase).
816    /// 3. Write the encrypted bundle to `XDG_CONFIG_HOME/ma/<slug>.bin`
817    ///    (or the path from `--secret-bundle`) with mode 0600.
818    /// 4. Write the YAML config to `XDG_CONFIG_HOME/ma/<slug>.yaml`
819    ///    (or the path from `--config`) with the passphrase in cleartext and
820    ///    mode 0600.
821    /// 5. Print the paths of both files to stdout.
822    ///
823    /// Returns an error if either file already exists.
824    #[cfg(not(target_arch = "wasm32"))]
825    pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
826        let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
827
828        let config_path = if let Some(ref p) = args.config {
829            p.clone()
830        } else {
831            default_config_path(&slug)?
832        };
833        let bundle_path = if let Some(ref p) = args.secret_bundle {
834            p.clone()
835        } else {
836            default_secret_bundle_path(&slug)?
837        };
838
839        if config_path.exists() {
840            return Err(Error::Config(format!(
841                "config file already exists: {} (remove it first or use --config)",
842                config_path.display()
843            )));
844        }
845        if bundle_path.exists() {
846            return Err(Error::Config(format!(
847                "secret bundle already exists: {} (remove it first or use --secret-bundle)",
848                bundle_path.display()
849            )));
850        }
851
852        // Generate or use provided passphrase.
853        let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
854            p.clone()
855        } else {
856            SecretBundle::generate_passphrase()
857        };
858
859        // Generate and save the bundle.
860        let bundle = SecretBundle::generate();
861        bundle.save(&bundle_path, &passphrase)?;
862
863        // Build and save the config.
864        let config = Config {
865            slug: slug.clone(),
866            log_level: DEFAULT_LOG_LEVEL.to_string(),
867            log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
868            did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
869            did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
870            log_file: None,
871            #[cfg(not(target_arch = "wasm32"))]
872            kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
873            #[cfg(not(target_arch = "wasm32"))]
874            kubo_key_alias: slug.clone(),
875            secret_bundle: Some(bundle_path.clone()),
876            secret_bundle_passphrase: Some(passphrase),
877            config_path: Some(config_path.clone()),
878            pin_remote: false,
879            pin_remote_service: None,
880            pin_remote_name: None,
881            pin_overwrite: true,
882            extra: serde_yaml::Mapping::new(),
883        };
884        config.save()?;
885
886        println!("Config:        {}", config_path.display());
887        println!("Secret bundle: {}", bundle_path.display());
888
889        Ok(())
890    }
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    #[test]
898    fn remote_pin_config_uses_caller_default_name() {
899        let config = Config::from_yaml_str(
900            r"
901pin_remote: true
902pin_remote_service: pinata
903",
904        )
905        .unwrap();
906
907        let remote = config
908            .remote_pin_config_with_default_name("ma-runtime-ma-root")
909            .unwrap()
910            .unwrap();
911        assert_eq!(remote.service, "pinata");
912        assert_eq!(remote.name, "ma-runtime-ma-root");
913        assert!(remote.overwrite);
914    }
915
916    #[test]
917    fn remote_pin_config_requires_service_when_enabled() {
918        let config = Config::from_yaml_str("pin_remote: true\n").unwrap();
919
920        let err = config
921            .remote_pin_config_with_default_name("ma-runtime-ma-root")
922            .unwrap_err();
923        assert!(err.to_string().contains("pin_remote_service"));
924    }
925
926    #[test]
927    fn yaml_round_trip_preserves_slug() {
928        let config = Config::from_yaml_str("slug: testing\n").unwrap();
929
930        assert_eq!(config.slug, "testing");
931        assert!(config.to_yaml_string().unwrap().contains("slug: testing"));
932    }
933
934    #[cfg(not(target_arch = "wasm32"))]
935    #[test]
936    fn cli_pin_remote_overrides_yaml_default() {
937        let config = Config::from_args(
938            &MaArgs {
939                pin_remote: Some(true),
940                pin_remote_service: Some("pinata".to_string()),
941                pin_remote_name: Some("custom-root".to_string()),
942                ..MaArgs::default()
943            },
944            "test",
945        )
946        .unwrap();
947
948        assert!(config.pin_remote);
949        assert_eq!(config.pin_remote_service.as_deref(), Some("pinata"));
950        assert_eq!(config.pin_remote_name.as_deref(), Some("custom-root"));
951    }
952
953    #[cfg(not(target_arch = "wasm32"))]
954    #[test]
955    fn explicit_config_uses_yaml_slug_without_relocating_config_path() {
956        let path = std::env::temp_dir().join(format!(
957            "ma-core-config-explicit-slug-{}.yaml",
958            std::process::id()
959        ));
960        std::fs::write(&path, "slug: testing\n").unwrap();
961
962        let config = Config::from_args(
963            &MaArgs {
964                config: Some(path.clone()),
965                ..MaArgs::default()
966            },
967            "ma",
968        )
969        .unwrap();
970
971        assert_eq!(config.slug, "testing");
972        assert_eq!(config.config_path.as_deref(), Some(path.as_path()));
973        let _ = std::fs::remove_file(path);
974    }
975
976    #[cfg(not(target_arch = "wasm32"))]
977    #[test]
978    fn cli_slug_overrides_yaml_slug() {
979        let path = std::env::temp_dir().join(format!(
980            "ma-core-config-cli-slug-{}.yaml",
981            std::process::id()
982        ));
983        std::fs::write(&path, "slug: yaml-name\n").unwrap();
984
985        let config = Config::from_args(
986            &MaArgs {
987                config: Some(path.clone()),
988                slug: Some("cli-name".to_string()),
989                ..MaArgs::default()
990            },
991            "ma",
992        )
993        .unwrap();
994
995        assert_eq!(config.slug, "cli-name");
996        assert_eq!(config.config_path.as_deref(), Some(path.as_path()));
997        let _ = std::fs::remove_file(path);
998    }
999}