1#[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
54const DEFAULT_LOG_LEVEL: &str = "info";
57const DEFAULT_LOG_LEVEL_STDOUT: &str = "info";
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#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct RemotePinConfig {
67 pub service: String,
68 pub name: String,
69 pub overwrite: bool,
70}
71
72#[derive(Debug, Clone)]
79pub struct Config {
80 pub slug: String,
83
84 pub log_level: String,
86
87 pub log_level_stdout: String,
89
90 pub did_resolver_positive_ttl_secs: u64,
93
94 pub did_resolver_negative_ttl_secs: u64,
97
98 pub log_file: Option<PathBuf>,
101
102 #[cfg(not(target_arch = "wasm32"))]
103 pub kubo_rpc_url: String,
105
106 #[cfg(not(target_arch = "wasm32"))]
107 pub kubo_key_alias: String,
109
110 pub secret_bundle: Option<PathBuf>,
112
113 pub secret_bundle_passphrase: Option<String>,
116
117 pub config_path: Option<PathBuf>,
119
120 pub pin_remote: bool,
122
123 pub pin_remote_service: Option<String>,
125
126 pub pin_remote_name: Option<String>,
128
129 pub pin_overwrite: bool,
131
132 pub extra: serde_yaml::Mapping,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct BrowserIdentityExport {
143 pub version: u8,
144 pub config_yaml: String,
145 pub encrypted_secret_bundle_base64: String,
146}
147
148impl BrowserIdentityExport {
149 pub fn new(config_yaml: String, encrypted_secret_bundle: &[u8]) -> Self {
150 Self {
151 version: 1,
152 config_yaml,
153 encrypted_secret_bundle_base64: B64.encode(encrypted_secret_bundle),
154 }
155 }
156
157 pub fn encrypted_secret_bundle_bytes(&self) -> Result<Vec<u8>> {
158 B64.decode(self.encrypted_secret_bundle_base64.as_bytes())
159 .map_err(|e| Error::Config(format!("invalid encrypted bundle base64: {e}")))
160 }
161
162 pub fn to_json_string(&self) -> Result<String> {
163 serde_json::to_string(self)
164 .map_err(|e| Error::Config(format!("failed to serialize browser export: {e}")))
165 }
166
167 pub fn from_json_str(json: &str) -> Result<Self> {
168 serde_json::from_str(json)
169 .map_err(|e| Error::Config(format!("failed to parse browser export JSON: {e}")))
170 }
171}
172
173#[cfg(not(target_arch = "wasm32"))]
176fn project_dirs() -> Result<directories::ProjectDirs> {
177 directories::ProjectDirs::from("", "ma", "ma")
178 .ok_or_else(|| Error::Config("cannot determine XDG base directories".to_string()))
179}
180
181#[cfg(not(target_arch = "wasm32"))]
183pub fn default_config_path(slug: &str) -> Result<PathBuf> {
184 Ok(project_dirs()?.config_dir().join(format!("{slug}.yaml")))
185}
186
187#[cfg(not(target_arch = "wasm32"))]
189pub fn default_secret_bundle_path(slug: &str) -> Result<PathBuf> {
190 Ok(project_dirs()?.config_dir().join(format!("{slug}.bin")))
191}
192
193#[cfg(not(target_arch = "wasm32"))]
195pub fn default_log_file_path(slug: &str) -> Result<PathBuf> {
196 Ok(project_dirs()?.data_dir().join(format!("{slug}.log")))
197}
198
199#[cfg(not(target_arch = "wasm32"))]
206pub(crate) fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
207 use std::io::Write;
208
209 if let Some(parent) = path.parent() {
210 std::fs::create_dir_all(parent).map_err(|e| {
211 Error::Config(format!("failed to create dir {}: {e}", parent.display()))
212 })?;
213 }
214
215 #[cfg(unix)]
216 let mut file = {
217 use std::os::unix::fs::OpenOptionsExt;
218 std::fs::OpenOptions::new()
219 .write(true)
220 .create(true)
221 .truncate(true)
222 .mode(0o600)
223 .open(path)
224 .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?
225 };
226
227 #[cfg(not(unix))]
228 let mut file = std::fs::OpenOptions::new()
229 .write(true)
230 .create(true)
231 .truncate(true)
232 .open(path)
233 .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?;
234
235 file.write_all(data)
236 .map_err(|e| Error::Config(format!("failed to write {}: {e}", path.display())))?;
237
238 #[cfg(unix)]
241 {
242 use std::os::unix::fs::PermissionsExt;
243 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
244 Error::Config(format!(
245 "failed to set permissions on {}: {e}",
246 path.display()
247 ))
248 })?;
249 }
250
251 Ok(())
252}
253
254#[cfg(all(not(target_arch = "wasm32"), unix))]
257fn check_permissions(path: &Path) {
258 use std::os::unix::fs::MetadataExt;
259 if let Ok(meta) = std::fs::metadata(path) {
260 let mode = meta.mode() & 0o777;
261 if mode > 0o600 {
262 tracing::warn!(
263 path = %path.display(),
264 mode = format!("{mode:04o}"),
265 "config file has permissions wider than 0600 — consider `chmod 0600 {}`",
266 path.display()
267 );
268 }
269 }
270}
271
272#[cfg(all(not(target_arch = "wasm32"), not(unix)))]
273fn check_permissions(_path: &Path) {}
274
275#[cfg(not(target_arch = "wasm32"))]
278fn load_yaml_mapping(path: &Path) -> Result<serde_yaml::Mapping> {
279 let content = std::fs::read_to_string(path)
280 .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
281 let val: serde_yaml::Value = serde_yaml::from_str(&content)
282 .map_err(|e| Error::Config(format!("invalid YAML in {}: {e}", path.display())))?;
283 if let serde_yaml::Value::Mapping(m) = val {
284 Ok(m)
285 } else {
286 Err(Error::Config(format!(
287 "config file {} must be a YAML mapping",
288 path.display()
289 )))
290 }
291}
292
293#[cfg(not(target_arch = "wasm32"))]
294fn yaml_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
295 m.get(serde_yaml::Value::String(key.to_string()))
296 .and_then(|v| v.as_str())
297 .map(String::from)
298}
299
300#[cfg(not(target_arch = "wasm32"))]
301fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
302 m.get(serde_yaml::Value::String(key.to_string()))
303 .and_then(|v| v.as_str())
304 .map(PathBuf::from)
305}
306
307#[cfg(not(target_arch = "wasm32"))]
308fn yaml_u64(m: &serde_yaml::Mapping, key: &str) -> Option<u64> {
309 m.get(serde_yaml::Value::String(key.to_string()))
310 .and_then(|v| match v {
311 serde_yaml::Value::Number(n) => n.as_u64(),
312 serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
313 _ => None,
314 })
315}
316
317#[cfg(not(target_arch = "wasm32"))]
318fn yaml_bool(m: &serde_yaml::Mapping, key: &str) -> Option<bool> {
319 m.get(serde_yaml::Value::String(key.to_string()))
320 .and_then(|v| match v {
321 serde_yaml::Value::Bool(b) => Some(*b),
322 serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
323 _ => None,
324 })
325}
326
327impl Config {
330 pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
334 let slug = slug.as_ref().to_string();
335 Self {
336 slug: slug.clone(),
337 log_level: DEFAULT_LOG_LEVEL.to_string(),
338 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
339 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
340 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
341 log_file: None,
342 #[cfg(not(target_arch = "wasm32"))]
343 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
344 #[cfg(not(target_arch = "wasm32"))]
345 kubo_key_alias: slug,
346 secret_bundle: None,
347 secret_bundle_passphrase: None,
348 config_path: None,
349 pin_remote: false,
350 pin_remote_service: None,
351 pin_remote_name: None,
352 pin_overwrite: true,
353 extra: serde_yaml::Mapping::new(),
354 }
355 }
356
357 pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
359 let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
360 .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
361 let mut m = match val {
362 serde_yaml::Value::Mapping(m) => m,
363 _ => {
364 return Err(Error::Config(
365 "config YAML must be a mapping at the top level".to_string(),
366 ));
367 }
368 };
369
370 let take_str = |map: &mut serde_yaml::Mapping, key: &str| {
371 map.remove(serde_yaml::Value::String(key.to_string()))
372 .and_then(|v| v.as_str().map(ToOwned::to_owned))
373 };
374
375 let take_path = |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(PathBuf::from))
378 };
379
380 let take_u64 = |map: &mut serde_yaml::Mapping, key: &str| {
381 map.remove(serde_yaml::Value::String(key.to_string()))
382 .and_then(|v| match v {
383 serde_yaml::Value::Number(n) => n.as_u64(),
384 serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
385 _ => None,
386 })
387 };
388
389 let take_bool = |map: &mut serde_yaml::Mapping, key: &str| {
390 map.remove(serde_yaml::Value::String(key.to_string()))
391 .and_then(|v| match v {
392 serde_yaml::Value::Bool(b) => Some(b),
393 serde_yaml::Value::String(s) => s.parse::<bool>().ok(),
394 _ => None,
395 })
396 };
397
398 let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
399 let log_level =
400 take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
401 let log_level_stdout = take_str(&mut m, "log_level_stdout")
402 .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
403 let did_resolver_positive_ttl_secs = take_u64(&mut m, "did_resolver_positive_ttl_secs")
404 .unwrap_or(DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS);
405 let did_resolver_negative_ttl_secs = take_u64(&mut m, "did_resolver_negative_ttl_secs")
406 .unwrap_or(DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS);
407 let _ignored_config_path = take_path(&mut m, "config_path");
409 let _ignored_old_pin_batch_size = take_u64(&mut m, "old_pin_batch_size");
411 #[cfg(not(target_arch = "wasm32"))]
412 let kubo_rpc_url =
413 take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
414 #[cfg(not(target_arch = "wasm32"))]
415 let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
416
417 Ok(Self {
418 slug,
419 log_level,
420 log_level_stdout,
421 did_resolver_positive_ttl_secs,
422 did_resolver_negative_ttl_secs,
423 log_file: take_path(&mut m, "log_file"),
424 #[cfg(not(target_arch = "wasm32"))]
425 kubo_rpc_url,
426 #[cfg(not(target_arch = "wasm32"))]
427 kubo_key_alias,
428 secret_bundle: take_path(&mut m, "secret_bundle"),
429 secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
430 config_path: None,
431 pin_remote: take_bool(&mut m, "pin_remote").unwrap_or(false),
432 pin_remote_service: take_str(&mut m, "pin_remote_service"),
433 pin_remote_name: take_str(&mut m, "pin_remote_name"),
434 pin_overwrite: take_bool(&mut m, "pin_overwrite").unwrap_or(true),
435 extra: m,
436 })
437 }
438
439 pub fn to_yaml_string(&self) -> Result<String> {
441 let mut m = self.extra.clone();
442
443 let mut set = |k: &str, v: serde_yaml::Value| {
444 m.insert(serde_yaml::Value::String(k.to_string()), v);
445 };
446
447 set(
451 "log_level",
452 serde_yaml::Value::String(self.log_level.clone()),
453 );
454 set(
455 "log_level_stdout",
456 serde_yaml::Value::String(self.log_level_stdout.clone()),
457 );
458 set(
459 "did_resolver_positive_ttl_secs",
460 serde_yaml::Value::Number(serde_yaml::Number::from(
461 self.did_resolver_positive_ttl_secs,
462 )),
463 );
464 set(
465 "did_resolver_negative_ttl_secs",
466 serde_yaml::Value::Number(serde_yaml::Number::from(
467 self.did_resolver_negative_ttl_secs,
468 )),
469 );
470 #[cfg(not(target_arch = "wasm32"))]
471 set(
472 "kubo_rpc_url",
473 serde_yaml::Value::String(self.kubo_rpc_url.clone()),
474 );
475 #[cfg(not(target_arch = "wasm32"))]
476 set(
477 "kubo_key_alias",
478 serde_yaml::Value::String(self.kubo_key_alias.clone()),
479 );
480
481 if let Some(ref p) = self.log_file {
482 set(
483 "log_file",
484 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
485 );
486 }
487 if let Some(ref p) = self.secret_bundle {
488 set(
489 "secret_bundle",
490 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
491 );
492 }
493 if let Some(ref pw) = self.secret_bundle_passphrase {
494 set(
495 "secret_bundle_passphrase",
496 serde_yaml::Value::String(pw.clone()),
497 );
498 }
499 set("pin_remote", serde_yaml::Value::Bool(self.pin_remote));
500 if let Some(ref service) = self.pin_remote_service {
501 set(
502 "pin_remote_service",
503 serde_yaml::Value::String(service.clone()),
504 );
505 }
506 if let Some(ref name) = self.pin_remote_name {
507 set("pin_remote_name", serde_yaml::Value::String(name.clone()));
508 }
509 set("pin_overwrite", serde_yaml::Value::Bool(self.pin_overwrite));
510
511 serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
512 .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
513 }
514
515 pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
520 let mut copy = self.clone();
521 copy.secret_bundle_passphrase = None;
522 copy.to_yaml_string()
523 }
524
525 #[cfg(not(target_arch = "wasm32"))]
526 #[allow(clippy::too_many_lines)]
554 pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
555 let slug = args
557 .slug
558 .clone()
559 .unwrap_or_else(|| default_slug.to_string());
560
561 let config_path = if let Some(ref p) = args.config {
563 p.clone()
564 } else {
565 default_config_path(&slug)?
566 };
567
568 let yaml = if config_path.exists() {
570 check_permissions(&config_path);
571 Some(load_yaml_mapping(&config_path)?)
572 } else {
573 None
574 };
575
576 let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
580 cli.or_else(|| std::env::var(format!("MA_{env_key}")).ok())
581 .or_else(|| {
582 yaml.as_ref()
583 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
584 })
585 .unwrap_or_else(|| default.to_string())
586 };
587
588 let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<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 };
595
596 let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
597 cli.or_else(|| {
598 std::env::var(format!("MA_{env_key}"))
599 .ok()
600 .map(PathBuf::from)
601 })
602 .or_else(|| {
603 yaml.as_ref()
604 .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
605 })
606 };
607
608 let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
609 cli.or_else(|| {
610 std::env::var(format!("MA_{env_key}"))
611 .ok()
612 .and_then(|v| v.parse::<u64>().ok())
613 })
614 .or_else(|| {
615 yaml.as_ref()
616 .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
617 })
618 .unwrap_or(default)
619 };
620
621 let resolve_bool = |cli: Option<bool>, env_key: &str, default: bool| -> bool {
622 cli.or_else(|| {
623 std::env::var(format!("MA_{env_key}"))
624 .ok()
625 .and_then(|v| v.parse::<bool>().ok())
626 })
627 .or_else(|| {
628 yaml.as_ref()
629 .and_then(|m| yaml_bool(m, &env_key.to_lowercase()))
630 })
631 .unwrap_or(default)
632 };
633
634 let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
635 let log_level_stdout = resolve_str(
636 args.log_level_stdout.clone(),
637 "LOG_LEVEL_STDOUT",
638 DEFAULT_LOG_LEVEL_STDOUT,
639 );
640 let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
641 let did_resolver_positive_ttl_secs = resolve_u64(
642 args.did_resolver_positive_ttl_secs,
643 "DID_RESOLVER_POSITIVE_TTL_SECS",
644 DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
645 );
646 let did_resolver_negative_ttl_secs = resolve_u64(
647 args.did_resolver_negative_ttl_secs,
648 "DID_RESOLVER_NEGATIVE_TTL_SECS",
649 DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
650 );
651 let kubo_rpc_url = resolve_str(
652 args.kubo_rpc_url.clone(),
653 "KUBO_RPC_URL",
654 DEFAULT_KUBO_RPC_URL,
655 );
656 let kubo_key_alias =
657 resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
658 let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
659 let secret_bundle_passphrase = resolve_opt_str(
660 args.secret_bundle_passphrase.clone(),
661 "SECRET_BUNDLE_PASSPHRASE",
662 );
663 let pin_remote = resolve_bool(args.pin_remote, "PIN_REMOTE", false);
664 let pin_remote_service =
665 resolve_opt_str(args.pin_remote_service.clone(), "PIN_REMOTE_SERVICE");
666 let pin_remote_name = resolve_opt_str(args.pin_remote_name.clone(), "PIN_REMOTE_NAME");
667 let pin_overwrite = resolve_bool(args.pin_overwrite, "PIN_OVERWRITE", true);
668
669 let known: &[&str] = &[
671 "slug",
672 "log_level",
673 "log_level_stdout",
674 "log_file",
675 "did_resolver_positive_ttl_secs",
676 "did_resolver_negative_ttl_secs",
677 "kubo_rpc_url",
678 "kubo_key_alias",
679 "secret_bundle",
680 "secret_bundle_passphrase",
681 "pin_remote",
682 "pin_remote_service",
683 "pin_remote_name",
684 "pin_overwrite",
685 "old_pin_batch_size",
687 "config_path",
689 ];
690 let extra = yaml
691 .map(|mut m| {
692 for k in known {
693 m.remove(serde_yaml::Value::String((*k).to_string()));
694 }
695 m
696 })
697 .unwrap_or_default();
698
699 Ok(Config {
700 slug,
701 log_level,
702 log_level_stdout,
703 did_resolver_positive_ttl_secs,
704 did_resolver_negative_ttl_secs,
705 log_file,
706 #[cfg(not(target_arch = "wasm32"))]
707 kubo_rpc_url,
708 #[cfg(not(target_arch = "wasm32"))]
709 kubo_key_alias,
710 secret_bundle,
711 secret_bundle_passphrase,
712 config_path: Some(config_path),
713 pin_remote,
714 pin_remote_service,
715 pin_remote_name,
716 pin_overwrite,
717 extra,
718 })
719 }
720
721 pub fn remote_pin_config_with_default_name(
724 &self,
725 default_name: impl Into<String>,
726 ) -> Result<Option<RemotePinConfig>> {
727 if !self.pin_remote {
728 return Ok(None);
729 }
730 let service = self
731 .pin_remote_service
732 .as_deref()
733 .map(str::trim)
734 .filter(|value| !value.is_empty())
735 .ok_or_else(|| Error::Config("pin_remote requires pin_remote_service".to_string()))?;
736 let default_name = default_name.into();
737 let name = self
738 .pin_remote_name
739 .as_deref()
740 .map(str::trim)
741 .filter(|value| !value.is_empty())
742 .unwrap_or(default_name.as_str());
743 Ok(Some(RemotePinConfig {
744 service: service.to_string(),
745 name: name.to_string(),
746 overwrite: self.pin_overwrite,
747 }))
748 }
749
750 #[cfg(not(target_arch = "wasm32"))]
753 pub fn effective_log_file(&self) -> Result<PathBuf> {
754 if let Some(ref p) = self.log_file {
755 Ok(p.clone())
756 } else {
757 default_log_file_path(&self.slug)
758 }
759 }
760
761 #[cfg(not(target_arch = "wasm32"))]
764 pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
765 if let Some(ref p) = self.secret_bundle {
766 Ok(p.clone())
767 } else {
768 default_secret_bundle_path(&self.slug)
769 }
770 }
771
772 #[must_use]
777 pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
778 crate::ipfs::IpfsGatewayResolver::default().with_cache_ttls(
779 web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
780 web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
781 )
782 }
783
784 #[cfg(not(target_arch = "wasm32"))]
790 pub fn save(&self) -> Result<()> {
791 let path = self
792 .config_path
793 .as_ref()
794 .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
795
796 let yaml_text = self.to_yaml_string()?;
797
798 write_secure(path, yaml_text.as_bytes())
799 }
800
801 #[cfg(not(target_arch = "wasm32"))]
815 pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
816 let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
817
818 let config_path = if let Some(ref p) = args.config {
819 p.clone()
820 } else {
821 default_config_path(&slug)?
822 };
823 let bundle_path = if let Some(ref p) = args.secret_bundle {
824 p.clone()
825 } else {
826 default_secret_bundle_path(&slug)?
827 };
828
829 if config_path.exists() {
830 return Err(Error::Config(format!(
831 "config file already exists: {} (remove it first or use --config)",
832 config_path.display()
833 )));
834 }
835 if bundle_path.exists() {
836 return Err(Error::Config(format!(
837 "secret bundle already exists: {} (remove it first or use --secret-bundle)",
838 bundle_path.display()
839 )));
840 }
841
842 let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
844 p.clone()
845 } else {
846 SecretBundle::generate_passphrase()
847 };
848
849 let bundle = SecretBundle::generate();
851 bundle.save(&bundle_path, &passphrase)?;
852
853 let config = Config {
855 slug: slug.clone(),
856 log_level: DEFAULT_LOG_LEVEL.to_string(),
857 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
858 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
859 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
860 log_file: None,
861 #[cfg(not(target_arch = "wasm32"))]
862 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
863 #[cfg(not(target_arch = "wasm32"))]
864 kubo_key_alias: slug.clone(),
865 secret_bundle: Some(bundle_path.clone()),
866 secret_bundle_passphrase: Some(passphrase),
867 config_path: Some(config_path.clone()),
868 pin_remote: false,
869 pin_remote_service: None,
870 pin_remote_name: None,
871 pin_overwrite: true,
872 extra: serde_yaml::Mapping::new(),
873 };
874 config.save()?;
875
876 println!("Config: {}", config_path.display());
877 println!("Secret bundle: {}", bundle_path.display());
878
879 Ok(())
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn remote_pin_config_uses_caller_default_name() {
889 let config = Config::from_yaml_str(
890 r"
891pin_remote: true
892pin_remote_service: pinata
893",
894 )
895 .unwrap();
896
897 let remote = config
898 .remote_pin_config_with_default_name("ma-runtime-ma-root")
899 .unwrap()
900 .unwrap();
901 assert_eq!(remote.service, "pinata");
902 assert_eq!(remote.name, "ma-runtime-ma-root");
903 assert!(remote.overwrite);
904 }
905
906 #[test]
907 fn remote_pin_config_requires_service_when_enabled() {
908 let config = Config::from_yaml_str("pin_remote: true\n").unwrap();
909
910 let err = config
911 .remote_pin_config_with_default_name("ma-runtime-ma-root")
912 .unwrap_err();
913 assert!(err.to_string().contains("pin_remote_service"));
914 }
915
916 #[cfg(not(target_arch = "wasm32"))]
917 #[test]
918 fn cli_pin_remote_overrides_yaml_default() {
919 let config = Config::from_args(
920 &MaArgs {
921 pin_remote: Some(true),
922 pin_remote_service: Some("pinata".to_string()),
923 pin_remote_name: Some("custom-root".to_string()),
924 ..MaArgs::default()
925 },
926 "test",
927 )
928 .unwrap();
929
930 assert!(config.pin_remote);
931 assert_eq!(config.pin_remote_service.as_deref(), Some("pinata"));
932 assert_eq!(config.pin_remote_name.as_deref(), Some("custom-root"));
933 }
934}