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(
402 "log_level",
403 serde_yaml::Value::String(self.log_level.clone()),
404 );
405 set(
406 "log_level_stdout",
407 serde_yaml::Value::String(self.log_level_stdout.clone()),
408 );
409 set(
410 "did_resolver_positive_ttl_secs",
411 serde_yaml::Value::Number(serde_yaml::Number::from(
412 self.did_resolver_positive_ttl_secs,
413 )),
414 );
415 set(
416 "did_resolver_negative_ttl_secs",
417 serde_yaml::Value::Number(serde_yaml::Number::from(
418 self.did_resolver_negative_ttl_secs,
419 )),
420 );
421 #[cfg(not(target_arch = "wasm32"))]
422 set(
423 "kubo_rpc_url",
424 serde_yaml::Value::String(self.kubo_rpc_url.clone()),
425 );
426 #[cfg(not(target_arch = "wasm32"))]
427 set(
428 "kubo_key_alias",
429 serde_yaml::Value::String(self.kubo_key_alias.clone()),
430 );
431
432 if let Some(ref p) = self.log_file {
433 set(
434 "log_file",
435 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
436 );
437 }
438 if let Some(ref p) = self.secret_bundle {
439 set(
440 "secret_bundle",
441 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
442 );
443 }
444 if let Some(ref pw) = self.secret_bundle_passphrase {
445 set(
446 "secret_bundle_passphrase",
447 serde_yaml::Value::String(pw.clone()),
448 );
449 }
450
451 serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
452 .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
453 }
454
455 pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
460 let mut copy = self.clone();
461 copy.secret_bundle_passphrase = None;
462 copy.to_yaml_string()
463 }
464
465 #[cfg(not(target_arch = "wasm32"))]
466 #[allow(clippy::too_many_lines)]
496 pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
497 let prefix = default_slug.to_uppercase().replace('-', "_");
500
501 let slug = args
503 .slug
504 .clone()
505 .unwrap_or_else(|| default_slug.to_string());
506
507 let config_path = if let Some(ref p) = args.config {
509 p.clone()
510 } else {
511 default_config_path(&slug)?
512 };
513
514 let yaml = if config_path.exists() {
516 check_permissions(&config_path);
517 Some(load_yaml_mapping(&config_path)?)
518 } else {
519 None
520 };
521
522 let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
526 cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
527 .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
528 .or_else(|| {
529 yaml.as_ref()
530 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
531 })
532 .unwrap_or_else(|| default.to_string())
533 };
534
535 let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
536 cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
537 .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
538 .or_else(|| {
539 yaml.as_ref()
540 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
541 })
542 };
543
544 let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
545 cli.or_else(|| {
546 std::env::var(format!("MA_{prefix}_{env_key}"))
547 .ok()
548 .map(PathBuf::from)
549 })
550 .or_else(|| {
551 std::env::var(format!("MA_{env_key}"))
552 .ok()
553 .map(PathBuf::from)
554 })
555 .or_else(|| {
556 yaml.as_ref()
557 .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
558 })
559 };
560
561 let resolve_u64 = |cli: Option<u64>, env_key: &str, default: u64| -> u64 {
562 cli.or_else(|| {
563 std::env::var(format!("MA_{prefix}_{env_key}"))
564 .ok()
565 .and_then(|v| v.parse::<u64>().ok())
566 })
567 .or_else(|| {
568 std::env::var(format!("MA_{env_key}"))
569 .ok()
570 .and_then(|v| v.parse::<u64>().ok())
571 })
572 .or_else(|| {
573 yaml.as_ref()
574 .and_then(|m| yaml_u64(m, &env_key.to_lowercase()))
575 })
576 .unwrap_or(default)
577 };
578
579 let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
580 let log_level_stdout = resolve_str(
581 args.log_level_stdout.clone(),
582 "LOG_LEVEL_STDOUT",
583 DEFAULT_LOG_LEVEL_STDOUT,
584 );
585 let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
586 let did_resolver_positive_ttl_secs = resolve_u64(
587 args.did_resolver_positive_ttl_secs,
588 "DID_RESOLVER_POSITIVE_TTL_SECS",
589 DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
590 );
591 let did_resolver_negative_ttl_secs = resolve_u64(
592 args.did_resolver_negative_ttl_secs,
593 "DID_RESOLVER_NEGATIVE_TTL_SECS",
594 DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
595 );
596 let kubo_rpc_url = resolve_str(
597 args.kubo_rpc_url.clone(),
598 "KUBO_RPC_URL",
599 DEFAULT_KUBO_RPC_URL,
600 );
601 let kubo_key_alias =
602 resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
603 let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
604 let secret_bundle_passphrase = resolve_opt_str(
605 args.secret_bundle_passphrase.clone(),
606 "SECRET_BUNDLE_PASSPHRASE",
607 );
608
609 let known: &[&str] = &[
611 "slug",
612 "log_level",
613 "log_level_stdout",
614 "log_file",
615 "did_resolver_positive_ttl_secs",
616 "did_resolver_negative_ttl_secs",
617 "kubo_rpc_url",
618 "kubo_key_alias",
619 "secret_bundle",
620 "secret_bundle_passphrase",
621 "config_path",
623 ];
624 let extra = yaml
625 .map(|mut m| {
626 for k in known {
627 m.remove(serde_yaml::Value::String((*k).to_string()));
628 }
629 m
630 })
631 .unwrap_or_default();
632
633 Ok(Config {
634 slug,
635 log_level,
636 log_level_stdout,
637 did_resolver_positive_ttl_secs,
638 did_resolver_negative_ttl_secs,
639 log_file,
640 #[cfg(not(target_arch = "wasm32"))]
641 kubo_rpc_url,
642 #[cfg(not(target_arch = "wasm32"))]
643 kubo_key_alias,
644 secret_bundle,
645 secret_bundle_passphrase,
646 config_path: Some(config_path),
647 extra,
648 })
649 }
650
651 #[cfg(not(target_arch = "wasm32"))]
654 pub fn effective_log_file(&self) -> Result<PathBuf> {
655 if let Some(ref p) = self.log_file {
656 Ok(p.clone())
657 } else {
658 default_log_file_path(&self.slug)
659 }
660 }
661
662 #[cfg(not(target_arch = "wasm32"))]
665 pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
666 if let Some(ref p) = self.secret_bundle {
667 Ok(p.clone())
668 } else {
669 default_secret_bundle_path(&self.slug)
670 }
671 }
672
673 #[must_use]
678 pub fn ipfs_gateway_resolver(&self) -> crate::ipfs::IpfsGatewayResolver {
679 crate::ipfs::IpfsGatewayResolver::default().with_cache_ttls(
680 web_time::Duration::from_secs(self.did_resolver_positive_ttl_secs),
681 web_time::Duration::from_secs(self.did_resolver_negative_ttl_secs),
682 )
683 }
684
685 #[cfg(not(target_arch = "wasm32"))]
691 pub fn save(&self) -> Result<()> {
692 let path = self
693 .config_path
694 .as_ref()
695 .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
696
697 let yaml_text = self.to_yaml_string()?;
698
699 write_secure(path, yaml_text.as_bytes())
700 }
701
702 #[cfg(not(target_arch = "wasm32"))]
716 pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
717 let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
718
719 let config_path = if let Some(ref p) = args.config {
720 p.clone()
721 } else {
722 default_config_path(&slug)?
723 };
724 let bundle_path = if let Some(ref p) = args.secret_bundle {
725 p.clone()
726 } else {
727 default_secret_bundle_path(&slug)?
728 };
729
730 if config_path.exists() {
731 return Err(Error::Config(format!(
732 "config file already exists: {} (remove it first or use --config)",
733 config_path.display()
734 )));
735 }
736 if bundle_path.exists() {
737 return Err(Error::Config(format!(
738 "secret bundle already exists: {} (remove it first or use --secret-bundle)",
739 bundle_path.display()
740 )));
741 }
742
743 let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
745 p.clone()
746 } else {
747 SecretBundle::generate_passphrase()
748 };
749
750 let bundle = SecretBundle::generate();
752 bundle.save(&bundle_path, &passphrase)?;
753
754 let config = Config {
756 slug: slug.clone(),
757 log_level: DEFAULT_LOG_LEVEL.to_string(),
758 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
759 did_resolver_positive_ttl_secs: DEFAULT_DID_RESOLVER_POSITIVE_TTL_SECS,
760 did_resolver_negative_ttl_secs: DEFAULT_DID_RESOLVER_NEGATIVE_TTL_SECS,
761 log_file: None,
762 #[cfg(not(target_arch = "wasm32"))]
763 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
764 #[cfg(not(target_arch = "wasm32"))]
765 kubo_key_alias: slug.clone(),
766 secret_bundle: Some(bundle_path.clone()),
767 secret_bundle_passphrase: Some(passphrase),
768 config_path: Some(config_path.clone()),
769 extra: serde_yaml::Mapping::new(),
770 };
771 config.save()?;
772
773 println!("Config: {}", config_path.display());
774 println!("Secret bundle: {}", bundle_path.display());
775
776 Ok(())
777 }
778}