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_get<'a>(m: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> {
300 m.get(serde_yaml::Value::String(key.to_string()))
301}
302
303#[cfg(not(target_arch = "wasm32"))]
304fn yaml_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
305 yaml_get(m, key).and_then(|v| v.as_str()).map(String::from)
306}
307
308#[cfg(not(target_arch = "wasm32"))]
309fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
310 yaml_get(m, key).and_then(|v| v.as_str()).map(PathBuf::from)
311}
312
313#[cfg(not(target_arch = "wasm32"))]
314fn yaml_u64(m: &serde_yaml::Mapping, key: &str) -> Option<u64> {
315 yaml_get(m, key).and_then(|v| match v {
316 serde_yaml::Value::Number(n) => n.as_u64(),
317 serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
318 _ => None,
319 })
320}
321
322#[cfg(not(target_arch = "wasm32"))]
323fn yaml_bool(m: &serde_yaml::Mapping, key: &str) -> Option<bool> {
324 yaml_get(m, key).and_then(|v| match v {
325 serde_yaml::Value::Bool(b) => Some(*b),
326 serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
327 _ => None,
328 })
329}
330
331impl Config {
334 pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
338 let slug = slug.as_ref().to_string();
339 Self {
340 slug: slug.clone(),
341 log_level: DEFAULT_LOG_LEVEL.to_string(),
342 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
343 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
344 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
345 log_file: None,
346 #[cfg(not(target_arch = "wasm32"))]
347 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
348 #[cfg(not(target_arch = "wasm32"))]
349 kubo_key_alias: slug,
350 secret_bundle: None,
351 secret_bundle_passphrase: None,
352 config_path: None,
353 pin_remote: false,
354 pin_remote_service: None,
355 pin_remote_name: None,
356 pin_overwrite: true,
357 extra: serde_yaml::Mapping::new(),
358 }
359 }
360
361 pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
363 let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
364 .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
365 let mut m = match val {
366 serde_yaml::Value::Mapping(m) => m,
367 _ => {
368 return Err(Error::Config(
369 "config YAML must be a mapping at the top level".to_string(),
370 ));
371 }
372 };
373
374 let take_str = |map: &mut serde_yaml::Mapping, key: &str| {
375 map.remove(serde_yaml::Value::String(key.to_string()))
376 .and_then(|v| v.as_str().map(ToOwned::to_owned))
377 };
378
379 let take_path = |map: &mut serde_yaml::Mapping, key: &str| {
380 map.remove(serde_yaml::Value::String(key.to_string()))
381 .and_then(|v| v.as_str().map(PathBuf::from))
382 };
383
384 let take_u64 = |map: &mut serde_yaml::Mapping, key: &str| {
385 map.remove(serde_yaml::Value::String(key.to_string()))
386 .and_then(|v| match v {
387 serde_yaml::Value::Number(n) => n.as_u64(),
388 serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
389 _ => None,
390 })
391 };
392
393 let take_bool = |map: &mut serde_yaml::Mapping, key: &str| {
394 map.remove(serde_yaml::Value::String(key.to_string()))
395 .and_then(|v| match v {
396 serde_yaml::Value::Bool(b) => Some(b),
397 serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
398 _ => None,
399 })
400 };
401
402 let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
403 let log_level =
404 take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
405 let log_level_stdout = take_str(&mut m, "log_level_stdout")
406 .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
407 let did_resolver_positive_ttl_secs = take_u64(&mut m, "did_resolver_positive_ttl_secs")
408 .unwrap_or(DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS);
409 let did_resolver_negative_ttl_secs = take_u64(&mut m, "did_resolver_negative_ttl_secs")
410 .unwrap_or(DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS);
411 let _ignored_config_path = take_path(&mut m, "config_path");
413 let _ignored_old_pin_batch_size = take_u64(&mut m, "old_pin_batch_size");
415 #[cfg(not(target_arch = "wasm32"))]
416 let kubo_rpc_url =
417 take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
418 #[cfg(not(target_arch = "wasm32"))]
419 let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
420
421 Ok(Self {
422 slug,
423 log_level,
424 log_level_stdout,
425 did_resolver_positive_ttl_secs,
426 did_resolver_negative_ttl_secs,
427 log_file: take_path(&mut m, "log_file"),
428 #[cfg(not(target_arch = "wasm32"))]
429 kubo_rpc_url,
430 #[cfg(not(target_arch = "wasm32"))]
431 kubo_key_alias,
432 secret_bundle: take_path(&mut m, "secret_bundle"),
433 secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
434 config_path: None,
435 pin_remote: take_bool(&mut m, "pin_remote").unwrap_or(false),
436 pin_remote_service: take_str(&mut m, "pin_remote_service"),
437 pin_remote_name: take_str(&mut m, "pin_remote_name"),
438 pin_overwrite: take_bool(&mut m, "pin_overwrite").unwrap_or(true),
439 extra: m,
440 })
441 }
442
443 pub fn to_yaml_string(&self) -> Result<String> {
445 let mut m = self.extra.clone();
446
447 let mut set = |k: &str, v: serde_yaml::Value| {
448 m.insert(serde_yaml::Value::String(k.to_string()), v);
449 };
450
451 set("slug", serde_yaml::Value::String(self.slug.clone()));
452 set(
453 "log_level",
454 serde_yaml::Value::String(self.log_level.clone()),
455 );
456 set(
457 "log_level_stdout",
458 serde_yaml::Value::String(self.log_level_stdout.clone()),
459 );
460 set(
461 "did_resolver_positive_ttl_secs",
462 serde_yaml::Value::Number(serde_yaml::Number::from(
463 self.did_resolver_positive_ttl_secs,
464 )),
465 );
466 set(
467 "did_resolver_negative_ttl_secs",
468 serde_yaml::Value::Number(serde_yaml::Number::from(
469 self.did_resolver_negative_ttl_secs,
470 )),
471 );
472 #[cfg(not(target_arch = "wasm32"))]
473 set(
474 "kubo_rpc_url",
475 serde_yaml::Value::String(self.kubo_rpc_url.clone()),
476 );
477 #[cfg(not(target_arch = "wasm32"))]
478 set(
479 "kubo_key_alias",
480 serde_yaml::Value::String(self.kubo_key_alias.clone()),
481 );
482
483 if let Some(ref p) = self.log_file {
484 set(
485 "log_file",
486 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
487 );
488 }
489 if let Some(ref p) = self.secret_bundle {
490 set(
491 "secret_bundle",
492 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
493 );
494 }
495 if let Some(ref pw) = self.secret_bundle_passphrase {
496 set(
497 "secret_bundle_passphrase",
498 serde_yaml::Value::String(pw.clone()),
499 );
500 }
501 set("pin_remote", serde_yaml::Value::Bool(self.pin_remote));
502 if let Some(ref service) = self.pin_remote_service {
503 set(
504 "pin_remote_service",
505 serde_yaml::Value::String(service.clone()),
506 );
507 }
508 if let Some(ref name) = self.pin_remote_name {
509 set("pin_remote_name", serde_yaml::Value::String(name.clone()));
510 }
511 set("pin_overwrite", serde_yaml::Value::Bool(self.pin_overwrite));
512
513 serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
514 .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
515 }
516
517 pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
522 let mut copy = self.clone();
523 copy.secret_bundle_passphrase = None;
524 copy.to_yaml_string()
525 }
526
527 #[cfg(not(target_arch = "wasm32"))]
528 #[allow(clippy::too_many_lines)]
556 pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
557 let config_slug = args
560 .slug
561 .clone()
562 .unwrap_or_else(|| default_slug.to_string());
563
564 let config_path = if let Some(ref p) = args.config {
566 p.clone()
567 } else {
568 default_config_path(&config_slug)?
569 };
570
571 let yaml = if config_path.exists() {
573 check_permissions(&config_path);
574 Some(load_yaml_mapping(&config_path)?)
575 } else {
576 None
577 };
578
579 let slug = args
580 .slug
581 .clone()
582 .or_else(|| yaml.as_ref().and_then(|m| yaml_str(m, "slug")))
583 .unwrap_or_else(|| default_slug.to_string());
584
585 let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
589 cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
590 .or_else(|| {
591 yaml.as_ref()
592 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
593 })
594 .unwrap_or_else(|| default.to_string())
595 };
596
597 let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
598 cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
599 .or_else(|| {
600 yaml.as_ref()
601 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
602 })
603 };
604
605 let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
606 cli.or_else(|| {
607 std::env::var(format!("MA_{env_key}"))
608 .ok()
609 .map(PathBuf::from)
610 })
611 .or_else(|| {
612 yaml.as_ref()
613 .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
614 })
615 };
616
617 let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
618 cli.or_else(|| {
619 std::env::var(format!("MA_{env_key}"))
620 .ok()
621 .and_then(|v| v.parse::<u64>().ok())
622 })
623 .or_else(|| {
624 yaml.as_ref()
625 .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
626 })
627 .unwrap_or(default)
628 };
629
630 let resolve_bool = |cli: Option<bool>, env_key: &str, default: bool| -> bool {
631 cli.or_else(|| {
632 std::env::var(format!("MA_{env_key}"))
633 .ok()
634 .and_then(|v| v.parse::<bool>().ok())
635 })
636 .or_else(|| {
637 yaml.as_ref()
638 .and_then(|m| yaml_bool(m, &env_key.to_lowercase()))
639 })
640 .unwrap_or(default)
641 };
642
643 let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
644 let log_level_stdout = resolve_str(
645 args.log_level_stdout.clone(),
646 "LOG_LEVEL_STDOUT",
647 DEFAULT_LOG_LEVEL_STDOUT,
648 );
649 let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
650 let did_resolver_positive_ttl_secs = resolve_u64(
651 args.did_resolver_positive_ttl_secs,
652 "DID_RESOLVER_POSITIVE_TTL_SECS",
653 DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
654 );
655 let did_resolver_negative_ttl_secs = resolve_u64(
656 args.did_resolver_negative_ttl_secs,
657 "DID_RESOLVER_NEGATIVE_TTL_SECS",
658 DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
659 );
660 let kubo_rpc_url = resolve_str(
661 args.kubo_rpc_url.clone(),
662 "KUBO_RPC_URL",
663 DEFAULT_KUBO_RPC_URL,
664 );
665 let kubo_key_alias =
666 resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
667 let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
668 let secret_bundle_passphrase = resolve_opt_str(
669 args.secret_bundle_passphrase.clone(),
670 "SECRET_BUNDLE_PASSPHRASE",
671 );
672 let pin_remote = resolve_bool(args.pin_remote, "PIN_REMOTE", false);
673 let pin_remote_service =
674 resolve_opt_str(args.pin_remote_service.clone(), "PIN_REMOTE_SERVICE");
675 let pin_remote_name = resolve_opt_str(args.pin_remote_name.clone(), "PIN_REMOTE_NAME");
676 let pin_overwrite = resolve_bool(args.pin_overwrite, "PIN_OVERWRITE", true);
677
678 let known: &[&str] = &[
680 "slug",
681 "log_level",
682 "log_level_stdout",
683 "log_file",
684 "did_resolver_positive_ttl_secs",
685 "did_resolver_negative_ttl_secs",
686 "kubo_rpc_url",
687 "kubo_key_alias",
688 "secret_bundle",
689 "secret_bundle_passphrase",
690 "pin_remote",
691 "pin_remote_service",
692 "pin_remote_name",
693 "pin_overwrite",
694 "old_pin_batch_size",
696 "config_path",
698 ];
699 let extra = yaml
700 .map(|mut m| {
701 for k in known {
702 m.remove(serde_yaml::Value::String((*k).to_string()));
703 }
704 m
705 })
706 .unwrap_or_default();
707
708 Ok(Config {
709 slug,
710 log_level,
711 log_level_stdout,
712 did_resolver_positive_ttl_secs,
713 did_resolver_negative_ttl_secs,
714 log_file,
715 #[cfg(not(target_arch = "wasm32"))]
716 kubo_rpc_url,
717 #[cfg(not(target_arch = "wasm32"))]
718 kubo_key_alias,
719 secret_bundle,
720 secret_bundle_passphrase,
721 config_path: Some(config_path),
722 pin_remote,
723 pin_remote_service,
724 pin_remote_name,
725 pin_overwrite,
726 extra,
727 })
728 }
729
730 pub fn remote_pin_config_with_default_name(
733 &self,
734 default_name: impl Into<String>,
735 ) -> Result<Option<RemotePinConfig>> {
736 if !self.pin_remote {
737 return Ok(None);
738 }
739 let service = self
740 .pin_remote_service
741 .as_deref()
742 .map(str::trim)
743 .filter(|value| !value.is_empty())
744 .ok_or_else(|| Error::Config("pin_remote requires pin_remote_service".to_string()))?;
745 let default_name = default_name.into();
746 let name = self
747 .pin_remote_name
748 .as_deref()
749 .map(str::trim)
750 .filter(|value| !value.is_empty())
751 .unwrap_or(default_name.as_str());
752 Ok(Some(RemotePinConfig {
753 service: service.to_string(),
754 name: name.to_string(),
755 overwrite: self.pin_overwrite,
756 }))
757 }
758
759 #[cfg(not(target_arch = "wasm32"))]
762 pub fn effective_log_file(&self) -> Result<PathBuf> {
763 if let Some(ref p) = self.log_file {
764 Ok(p.clone())
765 } else {
766 default_log_file_path(&self.slug)
767 }
768 }
769
770 #[cfg(not(target_arch = "wasm32"))]
773 pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
774 if let Some(ref p) = self.secret_bundle {
775 Ok(p.clone())
776 } else {
777 default_secret_bundle_path(&self.slug)
778 }
779 }
780
781 #[must_use]
786 pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
787 crate::ipfs::IpfsGatewayResolver::default().with_cache_ttls(
788 web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
789 web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
790 )
791 }
792
793 #[cfg(not(target_arch = "wasm32"))]
799 pub fn save(&self) -> Result<()> {
800 let path = self
801 .config_path
802 .as_ref()
803 .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
804
805 let yaml_text = self.to_yaml_string()?;
806
807 write_secure(path, yaml_text.as_bytes())
808 }
809
810 #[cfg(not(target_arch = "wasm32"))]
824 pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
825 let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
826
827 let config_path = if let Some(ref p) = args.config {
828 p.clone()
829 } else {
830 default_config_path(&slug)?
831 };
832 let bundle_path = if let Some(ref p) = args.secret_bundle {
833 p.clone()
834 } else {
835 default_secret_bundle_path(&slug)?
836 };
837
838 if config_path.exists() {
839 return Err(Error::Config(format!(
840 "config file already exists: {} (remove it first or use --config)",
841 config_path.display()
842 )));
843 }
844 if bundle_path.exists() {
845 return Err(Error::Config(format!(
846 "secret bundle already exists: {} (remove it first or use --secret-bundle)",
847 bundle_path.display()
848 )));
849 }
850
851 let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
853 p.clone()
854 } else {
855 SecretBundle::generate_passphrase()
856 };
857
858 let bundle = SecretBundle::generate();
860 bundle.save(&bundle_path, &passphrase)?;
861
862 let config = Config {
864 slug: slug.clone(),
865 log_level: DEFAULT_LOG_LEVEL.to_string(),
866 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
867 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
868 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
869 log_file: None,
870 #[cfg(not(target_arch = "wasm32"))]
871 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
872 #[cfg(not(target_arch = "wasm32"))]
873 kubo_key_alias: slug.clone(),
874 secret_bundle: Some(bundle_path.clone()),
875 secret_bundle_passphrase: Some(passphrase),
876 config_path: Some(config_path.clone()),
877 pin_remote: false,
878 pin_remote_service: None,
879 pin_remote_name: None,
880 pin_overwrite: true,
881 extra: serde_yaml::Mapping::new(),
882 };
883 config.save()?;
884
885 println!("Config: {}", config_path.display());
886 println!("Secret bundle: {}", bundle_path.display());
887
888 Ok(())
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895
896 #[test]
897 fn remote_pin_config_uses_caller_default_name() {
898 let config = Config::from_yaml_str(
899 r"
900pin_remote: true
901pin_remote_service: pinata
902",
903 )
904 .unwrap();
905
906 let remote = config
907 .remote_pin_config_with_default_name("ma-runtime-ma-root")
908 .unwrap()
909 .unwrap();
910 assert_eq!(remote.service, "pinata");
911 assert_eq!(remote.name, "ma-runtime-ma-root");
912 assert!(remote.overwrite);
913 }
914
915 #[test]
916 fn remote_pin_config_requires_service_when_enabled() {
917 let config = Config::from_yaml_str("pin_remote: true\n").unwrap();
918
919 let err = config
920 .remote_pin_config_with_default_name("ma-runtime-ma-root")
921 .unwrap_err();
922 assert!(err.to_string().contains("pin_remote_service"));
923 }
924
925 #[test]
926 fn yaml_round_trip_preserves_slug() {
927 let config = Config::from_yaml_str("slug: testing\n").unwrap();
928
929 assert_eq!(config.slug, "testing");
930 assert!(config.to_yaml_string().unwrap().contains("slug: testing"));
931 }
932
933 #[cfg(not(target_arch = "wasm32"))]
934 #[test]
935 fn cli_pin_remote_overrides_yaml_default() {
936 let config = Config::from_args(
937 &MaArgs {
938 pin_remote: Some(true),
939 pin_remote_service: Some("pinata".to_string()),
940 pin_remote_name: Some("custom-root".to_string()),
941 ..MaArgs::default()
942 },
943 "test",
944 )
945 .unwrap();
946
947 assert!(config.pin_remote);
948 assert_eq!(config.pin_remote_service.as_deref(), Some("pinata"));
949 assert_eq!(config.pin_remote_name.as_deref(), Some("custom-root"));
950 }
951
952 #[cfg(not(target_arch = "wasm32"))]
953 #[test]
954 fn explicit_config_uses_yaml_slug_without_relocating_config_path() {
955 let path = std::env::temp_dir().join(format!(
956 "ma-core-config-explicit-slug-{}.yaml",
957 std::process::id()
958 ));
959 std::fs::write(&path, "slug: testing\n").unwrap();
960
961 let config = Config::from_args(
962 &MaArgs {
963 config: Some(path.clone()),
964 ..MaArgs::default()
965 },
966 "ma",
967 )
968 .unwrap();
969
970 assert_eq!(config.slug, "testing");
971 assert_eq!(config.config_path.as_deref(), Some(path.as_path()));
972 let _ = std::fs::remove_file(path);
973 }
974
975 #[cfg(not(target_arch = "wasm32"))]
976 #[test]
977 fn cli_slug_overrides_yaml_slug() {
978 let path = std::env::temp_dir().join(format!(
979 "ma-core-config-cli-slug-{}.yaml",
980 std::process::id()
981 ));
982 std::fs::write(&path, "slug: yaml-name\n").unwrap();
983
984 let config = Config::from_args(
985 &MaArgs {
986 config: Some(path.clone()),
987 slug: Some("cli-name".to_string()),
988 ..MaArgs::default()
989 },
990 "ma",
991 )
992 .unwrap();
993
994 assert_eq!(config.slug, "cli-name");
995 assert_eq!(config.config_path.as_deref(), Some(path.as_path()));
996 let _ = std::fs::remove_file(path);
997 }
998}