1#[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
55const DEFAULT_LOG_LEVEL: &str = "info";
58const DEFAULT_LOG_LEVEL_STDOUT: &str = "warn";
59const DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS: u64 = 60;
60const DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS: u64 = 10;
61#[cfg(not(target_arch = "wasm32"))]
62const DEFAULT_KUBO_RPC_URL: &str = "http://127.0.0.1:5001";
63
64#[derive(Debug, Clone)]
71pub struct Config {
72 pub slug: String,
75
76 pub log_level: String,
78
79 pub log_level_stdout: String,
81
82 pub did_resolver_positive_ttl_secs: u64,
85
86 pub did_resolver_negative_ttl_secs: u64,
89
90 pub log_file: Option<PathBuf>,
93
94 #[cfg(not(target_arch = "wasm32"))]
95 pub kubo_rpc_url: String,
97
98 #[cfg(not(target_arch = "wasm32"))]
99 pub kubo_key_alias: String,
101
102 pub secret_bundle: Option<PathBuf>,
104
105 pub secret_bundle_passphrase: Option<String>,
108
109 pub config_path: Option<PathBuf>,
111
112 pub extra: serde_yaml::Mapping,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BrowserIdentityExport {
123 pub version: u8,
124 pub config_yaml: String,
125 pub encrypted_secret_bundle_base64: String,
126}
127
128impl BrowserIdentityExport {
129 pub fn new(config_yaml: String, encrypted_secret_bundle: &[u8]) -> Self {
130 Self {
131 version: 1,
132 config_yaml,
133 encrypted_secret_bundle_base64: B64.encode(encrypted_secret_bundle),
134 }
135 }
136
137 pub fn encrypted_secret_bundle_bytes(&self) -> Result<Vec<u8>> {
138 B64.decode(self.encrypted_secret_bundle_base64.as_bytes())
139 .map_err(|e| Error::Config(format!("invalid encrypted bundle base64: {e}")))
140 }
141
142 pub fn to_json_string(&self) -> Result<String> {
143 serde_json::to_string(self)
144 .map_err(|e| Error::Config(format!("failed to serialize browser export: {e}")))
145 }
146
147 pub fn from_json_str(json: &str) -> Result<Self> {
148 serde_json::from_str(json)
149 .map_err(|e| Error::Config(format!("failed to parse browser export JSON: {e}")))
150 }
151}
152
153#[cfg(not(target_arch = "wasm32"))]
156fn project_dirs() -> Result<directories::ProjectDirs> {
157 directories::ProjectDirs::from("", "ma", "ma")
158 .ok_or_else(|| Error::Config("cannot determine XDG base directories".to_string()))
159}
160
161#[cfg(not(target_arch = "wasm32"))]
163pub fn default_config_path(slug: &str) -> Result<PathBuf> {
164 Ok(project_dirs()?.config_dir().join(format!("{slug}.yaml")))
165}
166
167#[cfg(not(target_arch = "wasm32"))]
169pub fn default_secret_bundle_path(slug: &str) -> Result<PathBuf> {
170 Ok(project_dirs()?.config_dir().join(format!("{slug}.bin")))
171}
172
173#[cfg(not(target_arch = "wasm32"))]
175pub fn default_log_file_path(slug: &str) -> Result<PathBuf> {
176 Ok(project_dirs()?.data_dir().join(format!("{slug}.log")))
177}
178
179#[cfg(not(target_arch = "wasm32"))]
186pub(crate) fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
187 use std::io::Write;
188
189 if let Some(parent) = path.parent() {
190 std::fs::create_dir_all(parent).map_err(|e| {
191 Error::Config(format!("failed to create dir {}: {e}", parent.display()))
192 })?;
193 }
194
195 #[cfg(unix)]
196 let mut file = {
197 use std::os::unix::fs::OpenOptionsExt;
198 std::fs::OpenOptions::new()
199 .write(true)
200 .create(true)
201 .truncate(true)
202 .mode(0o600)
203 .open(path)
204 .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?
205 };
206
207 #[cfg(not(unix))]
208 let mut file = std::fs::OpenOptions::new()
209 .write(true)
210 .create(true)
211 .truncate(true)
212 .open(path)
213 .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?;
214
215 file.write_all(data)
216 .map_err(|e| Error::Config(format!("failed to write {}: {e}", path.display())))?;
217
218 #[cfg(unix)]
221 {
222 use std::os::unix::fs::PermissionsExt;
223 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
224 Error::Config(format!(
225 "failed to set permissions on {}: {e}",
226 path.display()
227 ))
228 })?;
229 }
230
231 Ok(())
232}
233
234#[cfg(all(not(target_arch = "wasm32"), unix))]
237fn check_permissions(path: &Path) {
238 use std::os::unix::fs::MetadataExt;
239 if let Ok(meta) = std::fs::metadata(path) {
240 let mode = meta.mode() & 0o777;
241 if mode > 0o600 {
242 tracing::warn!(
243 path = %path.display(),
244 mode = format!("{mode:04o}"),
245 "config file has permissions wider than 0600 — consider `chmod 0600 {}`",
246 path.display()
247 );
248 }
249 }
250}
251
252#[cfg(all(not(target_arch = "wasm32"), not(unix)))]
253fn check_permissions(_path: &Path) {}
254
255#[cfg(not(target_arch = "wasm32"))]
258fn load_yaml_mapping(path: &Path) -> Result<serde_yaml::Mapping> {
259 let content = std::fs::read_to_string(path)
260 .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
261 let val: serde_yaml::Value = serde_yaml::from_str(&content)
262 .map_err(|e| Error::Config(format!("invalid YAML in {}: {e}", path.display())))?;
263 if let serde_yaml::Value::Mapping(m) = val {
264 Ok(m)
265 } else {
266 Err(Error::Config(format!(
267 "config file {} must be a YAML mapping",
268 path.display()
269 )))
270 }
271}
272
273#[cfg(not(target_arch = "wasm32"))]
274fn yaml_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
275 m.get(serde_yaml::Value::String(key.to_string()))
276 .and_then(|v| v.as_str())
277 .map(String::from)
278}
279
280#[cfg(not(target_arch = "wasm32"))]
281fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
282 m.get(serde_yaml::Value::String(key.to_string()))
283 .and_then(|v| v.as_str())
284 .map(PathBuf::from)
285}
286
287#[cfg(not(target_arch = "wasm32"))]
288fn yaml_u64(m: &serde_yaml::Mapping, key: &str) -> Option<u64> {
289 m.get(serde_yaml::Value::String(key.to_string()))
290 .and_then(|v| match v {
291 serde_yaml::Value::Number(n) => n.as_u64(),
292 serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
293 _ => None,
294 })
295}
296
297impl Config {
300 pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
304 let slug = slug.as_ref().to_string();
305 Self {
306 slug: slug.clone(),
307 log_level: DEFAULT_LOG_LEVEL.to_string(),
308 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
309 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
310 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
311 log_file: None,
312 #[cfg(not(target_arch = "wasm32"))]
313 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
314 #[cfg(not(target_arch = "wasm32"))]
315 kubo_key_alias: slug,
316 secret_bundle: None,
317 secret_bundle_passphrase: None,
318 config_path: None,
319 extra: serde_yaml::Mapping::new(),
320 }
321 }
322
323 pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
325 let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
326 .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
327 let mut m = match val {
328 serde_yaml::Value::Mapping(m) => m,
329 _ => {
330 return Err(Error::Config(
331 "config YAML must be a mapping at the top level".to_string(),
332 ));
333 }
334 };
335
336 let take_str = |map: &mut serde_yaml::Mapping, key: &str| {
337 map.remove(serde_yaml::Value::String(key.to_string()))
338 .and_then(|v| v.as_str().map(ToOwned::to_owned))
339 };
340
341 let take_path = |map: &mut serde_yaml::Mapping, key: &str| {
342 map.remove(serde_yaml::Value::String(key.to_string()))
343 .and_then(|v| v.as_str().map(PathBuf::from))
344 };
345
346 let take_u64 = |map: &mut serde_yaml::Mapping, key: &str| {
347 map.remove(serde_yaml::Value::String(key.to_string()))
348 .and_then(|v| match v {
349 serde_yaml::Value::Number(n) => n.as_u64(),
350 serde_yaml::Value::String(s) => s.parse::<u64>().ok(),
351 _ => None,
352 })
353 };
354
355 let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
356 let log_level =
357 take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
358 let log_level_stdout = take_str(&mut m, "log_level_stdout")
359 .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
360 let did_resolver_positive_ttl_secs = take_u64(&mut m, "did_resolver_positive_ttl_secs")
361 .unwrap_or(DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS);
362 let did_resolver_negative_ttl_secs = take_u64(&mut m, "did_resolver_negative_ttl_secs")
363 .unwrap_or(DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS);
364 let _ignored_config_path = take_path(&mut m, "config_path");
366 #[cfg(not(target_arch = "wasm32"))]
367 let kubo_rpc_url =
368 take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
369 #[cfg(not(target_arch = "wasm32"))]
370 let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
371
372 Ok(Self {
373 slug,
374 log_level,
375 log_level_stdout,
376 did_resolver_positive_ttl_secs,
377 did_resolver_negative_ttl_secs,
378 log_file: take_path(&mut m, "log_file"),
379 #[cfg(not(target_arch = "wasm32"))]
380 kubo_rpc_url,
381 #[cfg(not(target_arch = "wasm32"))]
382 kubo_key_alias,
383 secret_bundle: take_path(&mut m, "secret_bundle"),
384 secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
385 config_path: None,
386 extra: m,
387 })
388 }
389
390 pub fn to_yaml_string(&self) -> Result<String> {
392 let mut m = self.extra.clone();
393
394 let mut set = |k: &str, v: serde_yaml::Value| {
395 m.insert(serde_yaml::Value::String(k.to_string()), v);
396 };
397
398 set("slug", serde_yaml::Value::String(self.slug.clone()));
399 set(
400 "log_level",
401 serde_yaml::Value::String(self.log_level.clone()),
402 );
403 set(
404 "log_level_stdout",
405 serde_yaml::Value::String(self.log_level_stdout.clone()),
406 );
407 set(
408 "did_resolver_positive_ttl_secs",
409 serde_yaml::Value::Number(serde_yaml::Number::from(
410 self.did_resolver_positive_ttl_secs,
411 )),
412 );
413 set(
414 "did_resolver_negative_ttl_secs",
415 serde_yaml::Value::Number(serde_yaml::Number::from(
416 self.did_resolver_negative_ttl_secs,
417 )),
418 );
419 #[cfg(not(target_arch = "wasm32"))]
420 set(
421 "kubo_rpc_url",
422 serde_yaml::Value::String(self.kubo_rpc_url.clone()),
423 );
424 #[cfg(not(target_arch = "wasm32"))]
425 set(
426 "kubo_key_alias",
427 serde_yaml::Value::String(self.kubo_key_alias.clone()),
428 );
429
430 if let Some(ref p) = self.log_file {
431 set(
432 "log_file",
433 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
434 );
435 }
436 if let Some(ref p) = self.secret_bundle {
437 set(
438 "secret_bundle",
439 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
440 );
441 }
442 if let Some(ref pw) = self.secret_bundle_passphrase {
443 set(
444 "secret_bundle_passphrase",
445 serde_yaml::Value::String(pw.clone()),
446 );
447 }
448
449 serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
450 .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
451 }
452
453 pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
458 let mut copy = self.clone();
459 copy.secret_bundle_passphrase = None;
460 copy.to_yaml_string()
461 }
462
463 #[cfg(not(target_arch = "wasm32"))]
464 #[allow(clippy::too_many_lines)]
494 pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
495 let prefix = default_slug.to_uppercase().replace('-', "_");
498
499 let slug = args
501 .slug
502 .clone()
503 .unwrap_or_else(|| default_slug.to_string());
504
505 let config_path = if let Some(ref p) = args.config {
507 p.clone()
508 } else {
509 default_config_path(&slug)?
510 };
511
512 let yaml = if config_path.exists() {
514 check_permissions(&config_path);
515 Some(load_yaml_mapping(&config_path)?)
516 } else {
517 None
518 };
519
520 let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
524 cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
525 .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
526 .or_else(|| {
527 yaml.as_ref()
528 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
529 })
530 .unwrap_or_else(|| default.to_string())
531 };
532
533 let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
534 cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
535 .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
536 .or_else(|| {
537 yaml.as_ref()
538 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
539 })
540 };
541
542 let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
543 cli.or_else(|| {
544 std::env::var(format!("MA_{prefix}_{env_key}"))
545 .ok()
546 .map(PathBuf::from)
547 })
548 .or_else(|| {
549 std::env::var(format!("MA_{env_key}"))
550 .ok()
551 .map(PathBuf::from)
552 })
553 .or_else(|| {
554 yaml.as_ref()
555 .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
556 })
557 };
558
559 let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
560 cli.or_else(|| {
561 std::env::var(format!("MA_{prefix}_{env_key}"))
562 .ok()
563 .and_then(|v| v.parse::<u64>().ok())
564 })
565 .or_else(|| {
566 std::env::var(format!("MA_{env_key}"))
567 .ok()
568 .and_then(|v| v.parse::<u64>().ok())
569 })
570 .or_else(|| {
571 yaml.as_ref()
572 .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
573 })
574 .unwrap_or(default)
575 };
576
577 let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
578 let log_level_stdout = resolve_str(
579 args.log_level_stdout.clone(),
580 "LOG_LEVEL_STDOUT",
581 DEFAULT_LOG_LEVEL_STDOUT,
582 );
583 let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
584 let did_resolver_positive_ttl_secs = resolve_u64(
585 args.did_resolver_positive_ttl_secs,
586 "DID_RESOLVER_POSITIVE_TTL_SECS",
587 DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
588 );
589 let did_resolver_negative_ttl_secs = resolve_u64(
590 args.did_resolver_negative_ttl_secs,
591 "DID_RESOLVER_NEGATIVE_TTL_SECS",
592 DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
593 );
594 let kubo_rpc_url = resolve_str(
595 args.kubo_rpc_url.clone(),
596 "KUBO_RPC_URL",
597 DEFAULT_KUBO_RPC_URL,
598 );
599 let kubo_key_alias =
600 resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
601 let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
602 let secret_bundle_passphrase = resolve_opt_str(
603 args.secret_bundle_passphrase.clone(),
604 "SECRET_BUNDLE_PASSPHRASE",
605 );
606
607 let known: &[&str] = &[
609 "slug",
610 "log_level",
611 "log_level_stdout",
612 "log_file",
613 "did_resolver_positive_ttl_secs",
614 "did_resolver_negative_ttl_secs",
615 "kubo_rpc_url",
616 "kubo_key_alias",
617 "secret_bundle",
618 "secret_bundle_passphrase",
619 "config_path",
621 ];
622 let extra = yaml
623 .map(|mut m| {
624 for k in known {
625 m.remove(serde_yaml::Value::String((*k).to_string()));
626 }
627 m
628 })
629 .unwrap_or_default();
630
631 Ok(Config {
632 slug,
633 log_level,
634 log_level_stdout,
635 did_resolver_positive_ttl_secs,
636 did_resolver_negative_ttl_secs,
637 log_file,
638 #[cfg(not(target_arch = "wasm32"))]
639 kubo_rpc_url,
640 #[cfg(not(target_arch = "wasm32"))]
641 kubo_key_alias,
642 secret_bundle,
643 secret_bundle_passphrase,
644 config_path: Some(config_path),
645 extra,
646 })
647 }
648
649 #[cfg(not(target_arch = "wasm32"))]
652 pub fn effective_log_file(&self) -> Result<PathBuf> {
653 if let Some(ref p) = self.log_file {
654 Ok(p.clone())
655 } else {
656 default_log_file_path(&self.slug)
657 }
658 }
659
660 #[cfg(not(target_arch = "wasm32"))]
663 pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
664 if let Some(ref p) = self.secret_bundle {
665 Ok(p.clone())
666 } else {
667 default_secret_bundle_path(&self.slug)
668 }
669 }
670
671 #[cfg(not(target_arch = "wasm32"))]
673 #[must_use]
674 pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
675 crate::ipfs::IpfsGatewayResolver::new(self.kubo_rpc_url.clone()).with_cache_ttls(
676 web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
677 web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
678 )
679 }
680
681 #[cfg(not(target_arch = "wasm32"))]
687 pub fn save(&self) -> Result<()> {
688 let path = self
689 .config_path
690 .as_ref()
691 .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
692
693 let yaml_text = self.to_yaml_string()?;
694
695 write_secure(path, yaml_text.as_bytes())
696 }
697
698 #[cfg(not(target_arch = "wasm32"))]
712 pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
713 let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
714
715 let config_path = if let Some(ref p) = args.config {
716 p.clone()
717 } else {
718 default_config_path(&slug)?
719 };
720 let bundle_path = if let Some(ref p) = args.secret_bundle {
721 p.clone()
722 } else {
723 default_secret_bundle_path(&slug)?
724 };
725
726 if config_path.exists() {
727 return Err(Error::Config(format!(
728 "config file already exists: {} (remove it first or use --config)",
729 config_path.display()
730 )));
731 }
732 if bundle_path.exists() {
733 return Err(Error::Config(format!(
734 "secret bundle already exists: {} (remove it first or use --secret-bundle)",
735 bundle_path.display()
736 )));
737 }
738
739 let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
741 p.clone()
742 } else {
743 SecretBundle::generate_passphrase()
744 };
745
746 let bundle = SecretBundle::generate();
748 bundle.save(&bundle_path, &passphrase)?;
749
750 let config = Config {
752 slug: slug.clone(),
753 log_level: DEFAULT_LOG_LEVEL.to_string(),
754 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
755 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
756 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
757 log_file: None,
758 #[cfg(not(target_arch = "wasm32"))]
759 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
760 #[cfg(not(target_arch = "wasm32"))]
761 kubo_key_alias: slug.clone(),
762 secret_bundle: Some(bundle_path.clone()),
763 secret_bundle_passphrase: Some(passphrase),
764 config_path: Some(config_path.clone()),
765 extra: serde_yaml::Mapping::new(),
766 };
767 config.save()?;
768
769 println!("Config: {}", config_path.display());
770 println!("Secret bundle: {}", bundle_path.display());
771
772 Ok(())
773 }
774}