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