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