1#[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
58const 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#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct RemotePinConfig {
71 pub service: String,
72 pub name: String,
73 pub overwrite: bool,
74}
75
76#[derive(Debug, Clone)]
83pub struct Config {
84 pub slug: String,
88
89 pub log_level: String,
91
92 pub log_level_stdout: String,
94
95 pub did_resolver_positive_ttl_secs: u64,
98
99 pub did_resolver_negative_ttl_secs: u64,
102
103 pub log_file: Option<PathBuf>,
106
107 #[cfg(not(target_arch = "wasm32"))]
108 pub kubo_rpc_url: String,
110
111 #[cfg(not(target_arch = "wasm32"))]
112 pub kubo_key_alias: String,
114
115 pub secret_bundle: Option<PathBuf>,
117
118 pub secret_bundle_passphrase: Option<String>,
121
122 pub config_path: Option<PathBuf>,
124
125 pub pin_remote: bool,
127
128 pub pin_remote_service: Option<String>,
130
131 pub pin_remote_name: Option<String>,
133
134 pub pin_overwrite: bool,
136
137 pub extra: serde_yaml::Mapping,
140}
141
142#[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#[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#[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#[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#[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#[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 #[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#[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#[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
332impl Config {
335 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 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 let _ignored_config_path = take_path(&mut m, "config_path");
414 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 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 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 #[allow(clippy::too_many_lines)]
557 pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
558 let config_slug = args
561 .slug
562 .clone()
563 .unwrap_or_else(|| default_slug.to_string());
564
565 let config_path = if let Some(ref p) = args.config {
567 p.clone()
568 } else {
569 default_config_path(&config_slug)?
570 };
571
572 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 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 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 "old_pin_batch_size",
697 "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 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 #[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 #[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 #[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 #[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 #[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 let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
854 p.clone()
855 } else {
856 SecretBundle::generate_passphrase()
857 };
858
859 let bundle = SecretBundle::generate();
861 bundle.save(&bundle_path, &passphrase)?;
862
863 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}