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";
59#[cfg(not(target_arch = "wasm32"))]
60const DEFAULT_KUBO_RPC_URL: &str = "http://127.0.0.1:5001";
61
62#[derive(Debug, Clone)]
69pub struct Config {
70 pub slug: String,
73
74 pub log_level: String,
76
77 pub log_level_stdout: String,
79
80 pub log_file: Option<PathBuf>,
83
84 #[cfg(not(target_arch = "wasm32"))]
85 pub kubo_rpc_url: String,
87
88 #[cfg(not(target_arch = "wasm32"))]
89 pub kubo_key_alias: String,
91
92 pub secret_bundle: Option<PathBuf>,
94
95 pub secret_bundle_passphrase: Option<String>,
98
99 pub config_path: Option<PathBuf>,
101
102 pub extra: serde_yaml::Mapping,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct BrowserIdentityExport {
113 pub version: u8,
114 pub config_yaml: String,
115 pub encrypted_secret_bundle_base64: String,
116}
117
118impl BrowserIdentityExport {
119 pub fn new(config_yaml: String, encrypted_secret_bundle: &[u8]) -> Self {
120 Self {
121 version: 1,
122 config_yaml,
123 encrypted_secret_bundle_base64: B64.encode(encrypted_secret_bundle),
124 }
125 }
126
127 pub fn encrypted_secret_bundle_bytes(&self) -> Result<Vec<u8>> {
128 B64.decode(self.encrypted_secret_bundle_base64.as_bytes())
129 .map_err(|e| Error::Config(format!("invalid encrypted bundle base64: {e}")))
130 }
131
132 pub fn to_json_string(&self) -> Result<String> {
133 serde_json::to_string(self)
134 .map_err(|e| Error::Config(format!("failed to serialize browser export: {e}")))
135 }
136
137 pub fn from_json_str(json: &str) -> Result<Self> {
138 serde_json::from_str(json)
139 .map_err(|e| Error::Config(format!("failed to parse browser export JSON: {e}")))
140 }
141}
142
143#[cfg(not(target_arch = "wasm32"))]
146fn project_dirs() -> Result<directories::ProjectDirs> {
147 directories::ProjectDirs::from("", "ma", "ma")
148 .ok_or_else(|| Error::Config("cannot determine XDG base directories".to_string()))
149}
150
151#[cfg(not(target_arch = "wasm32"))]
153pub fn default_config_path(slug: &str) -> Result<PathBuf> {
154 Ok(project_dirs()?.config_dir().join(format!("{slug}.yaml")))
155}
156
157#[cfg(not(target_arch = "wasm32"))]
159pub fn default_secret_bundle_path(slug: &str) -> Result<PathBuf> {
160 Ok(project_dirs()?.config_dir().join(format!("{slug}.bin")))
161}
162
163#[cfg(not(target_arch = "wasm32"))]
165pub fn default_log_file_path(slug: &str) -> Result<PathBuf> {
166 Ok(project_dirs()?.data_dir().join(format!("{slug}.log")))
167}
168
169#[cfg(not(target_arch = "wasm32"))]
176pub(crate) fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
177 use std::io::Write;
178
179 if let Some(parent) = path.parent() {
180 std::fs::create_dir_all(parent).map_err(|e| {
181 Error::Config(format!("failed to create dir {}: {e}", parent.display()))
182 })?;
183 }
184
185 #[cfg(unix)]
186 let mut file = {
187 use std::os::unix::fs::OpenOptionsExt;
188 std::fs::OpenOptions::new()
189 .write(true)
190 .create(true)
191 .truncate(true)
192 .mode(0o600)
193 .open(path)
194 .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?
195 };
196
197 #[cfg(not(unix))]
198 let mut file = std::fs::OpenOptions::new()
199 .write(true)
200 .create(true)
201 .truncate(true)
202 .open(path)
203 .map_err(|e| Error::Config(format!("failed to open {}: {e}", path.display())))?;
204
205 file.write_all(data)
206 .map_err(|e| Error::Config(format!("failed to write {}: {e}", path.display())))?;
207
208 #[cfg(unix)]
211 {
212 use std::os::unix::fs::PermissionsExt;
213 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
214 Error::Config(format!(
215 "failed to set permissions on {}: {e}",
216 path.display()
217 ))
218 })?;
219 }
220
221 Ok(())
222}
223
224#[cfg(all(not(target_arch = "wasm32"), unix))]
227fn check_permissions(path: &Path) {
228 use std::os::unix::fs::MetadataExt;
229 if let Ok(meta) = std::fs::metadata(path) {
230 let mode = meta.mode() & 0o777;
231 if mode > 0o600 {
232 tracing::warn!(
233 path = %path.display(),
234 mode = format!("{mode:04o}"),
235 "config file has permissions wider than 0600 — consider `chmod 0600 {}`",
236 path.display()
237 );
238 }
239 }
240}
241
242#[cfg(all(not(target_arch = "wasm32"), not(unix)))]
243fn check_permissions(_path: &Path) {}
244
245#[cfg(not(target_arch = "wasm32"))]
248fn load_yaml_mapping(path: &Path) -> Result<serde_yaml::Mapping> {
249 let content = std::fs::read_to_string(path)
250 .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
251 let val: serde_yaml::Value = serde_yaml::from_str(&content)
252 .map_err(|e| Error::Config(format!("invalid YAML in {}: {e}", path.display())))?;
253 if let serde_yaml::Value::Mapping(m) = val {
254 Ok(m)
255 } else {
256 Err(Error::Config(format!(
257 "config file {} must be a YAML mapping",
258 path.display()
259 )))
260 }
261}
262
263#[cfg(not(target_arch = "wasm32"))]
264fn yaml_str(m: &serde_yaml::Mapping, key: &str) -> Option<String> {
265 m.get(serde_yaml::Value::String(key.to_string()))
266 .and_then(|v| v.as_str())
267 .map(String::from)
268}
269
270#[cfg(not(target_arch = "wasm32"))]
271fn yaml_path(m: &serde_yaml::Mapping, key: &str) -> Option<PathBuf> {
272 m.get(serde_yaml::Value::String(key.to_string()))
273 .and_then(|v| v.as_str())
274 .map(PathBuf::from)
275}
276
277impl Config {
280 pub fn new_for_storage(slug: impl AsRef<str>) -> Self {
284 let slug = slug.as_ref().to_string();
285 Self {
286 slug: slug.clone(),
287 log_level: DEFAULT_LOG_LEVEL.to_string(),
288 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
289 log_file: None,
290 #[cfg(not(target_arch = "wasm32"))]
291 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
292 #[cfg(not(target_arch = "wasm32"))]
293 kubo_key_alias: slug,
294 secret_bundle: None,
295 secret_bundle_passphrase: None,
296 config_path: None,
297 extra: serde_yaml::Mapping::new(),
298 }
299 }
300
301 pub fn from_yaml_str(yaml_text: &str) -> Result<Self> {
303 let val: serde_yaml::Value = serde_yaml::from_str(yaml_text)
304 .map_err(|e| Error::Config(format!("failed to parse config YAML: {e}")))?;
305 let mut m = match val {
306 serde_yaml::Value::Mapping(m) => m,
307 _ => {
308 return Err(Error::Config(
309 "config YAML must be a mapping at the top level".to_string(),
310 ));
311 }
312 };
313
314 let take_str = |map: &mut serde_yaml::Mapping, key: &str| {
315 map.remove(serde_yaml::Value::String(key.to_string()))
316 .and_then(|v| v.as_str().map(ToOwned::to_owned))
317 };
318
319 let take_path = |map: &mut serde_yaml::Mapping, key: &str| {
320 map.remove(serde_yaml::Value::String(key.to_string()))
321 .and_then(|v| v.as_str().map(PathBuf::from))
322 };
323
324 let slug = take_str(&mut m, "slug").unwrap_or_else(|| "ma".to_string());
325 let log_level =
326 take_str(&mut m, "log_level").unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string());
327 let log_level_stdout = take_str(&mut m, "log_level_stdout")
328 .unwrap_or_else(|| DEFAULT_LOG_LEVEL_STDOUT.to_string());
329 #[cfg(not(target_arch = "wasm32"))]
330 let kubo_rpc_url =
331 take_str(&mut m, "kubo_rpc_url").unwrap_or_else(|| DEFAULT_KUBO_RPC_URL.to_string());
332 #[cfg(not(target_arch = "wasm32"))]
333 let kubo_key_alias = take_str(&mut m, "kubo_key_alias").unwrap_or_else(|| slug.clone());
334
335 Ok(Self {
336 slug,
337 log_level,
338 log_level_stdout,
339 log_file: take_path(&mut m, "log_file"),
340 #[cfg(not(target_arch = "wasm32"))]
341 kubo_rpc_url,
342 #[cfg(not(target_arch = "wasm32"))]
343 kubo_key_alias,
344 secret_bundle: take_path(&mut m, "secret_bundle"),
345 secret_bundle_passphrase: take_str(&mut m, "secret_bundle_passphrase"),
346 config_path: take_path(&mut m, "config_path"),
347 extra: m,
348 })
349 }
350
351 pub fn to_yaml_string(&self) -> Result<String> {
353 let mut m = self.extra.clone();
354
355 let mut set = |k: &str, v: serde_yaml::Value| {
356 m.insert(serde_yaml::Value::String(k.to_string()), v);
357 };
358
359 set("slug", serde_yaml::Value::String(self.slug.clone()));
360 set(
361 "log_level",
362 serde_yaml::Value::String(self.log_level.clone()),
363 );
364 set(
365 "log_level_stdout",
366 serde_yaml::Value::String(self.log_level_stdout.clone()),
367 );
368 #[cfg(not(target_arch = "wasm32"))]
369 set(
370 "kubo_rpc_url",
371 serde_yaml::Value::String(self.kubo_rpc_url.clone()),
372 );
373 #[cfg(not(target_arch = "wasm32"))]
374 set(
375 "kubo_key_alias",
376 serde_yaml::Value::String(self.kubo_key_alias.clone()),
377 );
378
379 if let Some(ref p) = self.log_file {
380 set(
381 "log_file",
382 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
383 );
384 }
385 if let Some(ref p) = self.secret_bundle {
386 set(
387 "secret_bundle",
388 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
389 );
390 }
391 if let Some(ref p) = self.config_path {
392 set(
393 "config_path",
394 serde_yaml::Value::String(p.to_string_lossy().into_owned()),
395 );
396 }
397 if let Some(ref pw) = self.secret_bundle_passphrase {
398 set(
399 "secret_bundle_passphrase",
400 serde_yaml::Value::String(pw.clone()),
401 );
402 }
403
404 serde_yaml::to_string(&serde_yaml::Value::Mapping(m))
405 .map_err(|e| Error::Config(format!("failed to serialize config: {e}")))
406 }
407
408 pub fn to_yaml_string_without_passphrase(&self) -> Result<String> {
413 let mut copy = self.clone();
414 copy.secret_bundle_passphrase = None;
415 copy.to_yaml_string()
416 }
417
418 #[cfg(not(target_arch = "wasm32"))]
419 pub fn from_args(args: &MaArgs, default_slug: &'static str) -> Result<Self> {
449 let prefix = default_slug.to_uppercase().replace('-', "_");
452
453 let slug = args
455 .slug
456 .clone()
457 .unwrap_or_else(|| default_slug.to_string());
458
459 let config_path = if let Some(ref p) = args.config {
461 p.clone()
462 } else {
463 default_config_path(&slug)?
464 };
465
466 let yaml = if config_path.exists() {
468 check_permissions(&config_path);
469 Some(load_yaml_mapping(&config_path)?)
470 } else {
471 None
472 };
473
474 let resolve_str = |cli: Option<String>, env_key: &str, default: &str| -> String {
478 cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
479 .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
480 .or_else(|| {
481 yaml.as_ref()
482 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
483 })
484 .unwrap_or_else(|| default.to_string())
485 };
486
487 let resolve_opt_str = |cli: Option<String>, env_key: &str| -> Option<String> {
488 cli.or_else(|| std::env::var(format!("MA_{prefix}_{env_key}")).ok())
489 .or_else(|| std::env::var(format!("MA_{env_key}")).ok())
490 .or_else(|| {
491 yaml.as_ref()
492 .and_then(|m| yaml_str(m, &env_key.to_lowercase()))
493 })
494 };
495
496 let resolve_opt_path = |cli: Option<PathBuf>, env_key: &str| -> Option<PathBuf> {
497 cli.or_else(|| {
498 std::env::var(format!("MA_{prefix}_{env_key}"))
499 .ok()
500 .map(PathBuf::from)
501 })
502 .or_else(|| {
503 std::env::var(format!("MA_{env_key}"))
504 .ok()
505 .map(PathBuf::from)
506 })
507 .or_else(|| {
508 yaml.as_ref()
509 .and_then(|m| yaml_path(m, &env_key.to_lowercase()))
510 })
511 };
512
513 let log_level = resolve_str(args.log_level.clone(), "LOG_LEVEL", DEFAULT_LOG_LEVEL);
514 let log_level_stdout = resolve_str(
515 args.log_level_stdout.clone(),
516 "LOG_LEVEL_STDOUT",
517 DEFAULT_LOG_LEVEL_STDOUT,
518 );
519 let log_file = resolve_opt_path(args.log_file.clone(), "LOG_FILE");
520 let kubo_rpc_url = resolve_str(
521 args.kubo_rpc_url.clone(),
522 "KUBO_RPC_URL",
523 DEFAULT_KUBO_RPC_URL,
524 );
525 let kubo_key_alias =
526 resolve_str(args.kubo_key_alias.clone(), "KUBO_KEY_ALIAS", &slug.clone());
527 let secret_bundle = resolve_opt_path(args.secret_bundle.clone(), "SECRET_BUNDLE");
528 let secret_bundle_passphrase = resolve_opt_str(
529 args.secret_bundle_passphrase.clone(),
530 "SECRET_BUNDLE_PASSPHRASE",
531 );
532
533 let known: &[&str] = &[
535 "slug",
536 "log_level",
537 "log_level_stdout",
538 "log_file",
539 "kubo_rpc_url",
540 "kubo_key_alias",
541 "secret_bundle",
542 "secret_bundle_passphrase",
543 ];
544 let extra = yaml
545 .map(|mut m| {
546 for k in known {
547 m.remove(serde_yaml::Value::String((*k).to_string()));
548 }
549 m
550 })
551 .unwrap_or_default();
552
553 Ok(Config {
554 slug,
555 log_level,
556 log_level_stdout,
557 log_file,
558 #[cfg(not(target_arch = "wasm32"))]
559 kubo_rpc_url,
560 #[cfg(not(target_arch = "wasm32"))]
561 kubo_key_alias,
562 secret_bundle,
563 secret_bundle_passphrase,
564 config_path: Some(config_path),
565 extra,
566 })
567 }
568
569 #[cfg(not(target_arch = "wasm32"))]
572 pub fn effective_log_file(&self) -> Result<PathBuf> {
573 if let Some(ref p) = self.log_file {
574 Ok(p.clone())
575 } else {
576 default_log_file_path(&self.slug)
577 }
578 }
579
580 #[cfg(not(target_arch = "wasm32"))]
583 pub fn effective_secret_bundle(&self) -> Result<PathBuf> {
584 if let Some(ref p) = self.secret_bundle {
585 Ok(p.clone())
586 } else {
587 default_secret_bundle_path(&self.slug)
588 }
589 }
590
591 #[cfg(not(target_arch = "wasm32"))]
597 pub fn save(&self) -> Result<()> {
598 let path = self
599 .config_path
600 .as_ref()
601 .ok_or_else(|| Error::Config("cannot save config: no config_path set".to_string()))?;
602
603 let yaml_text = self.to_yaml_string()?;
604
605 write_secure(path, yaml_text.as_bytes())
606 }
607
608 #[cfg(not(target_arch = "wasm32"))]
622 pub fn gen_headless(args: &MaArgs, default_slug: &'static str) -> Result<()> {
623 use base64::engine::general_purpose::STANDARD;
624 use base64::Engine;
625
626 let slug = args.slug.as_deref().unwrap_or(default_slug).to_string();
627
628 let config_path = if let Some(ref p) = args.config {
629 p.clone()
630 } else {
631 default_config_path(&slug)?
632 };
633 let bundle_path = if let Some(ref p) = args.secret_bundle {
634 p.clone()
635 } else {
636 default_secret_bundle_path(&slug)?
637 };
638
639 if config_path.exists() {
640 return Err(Error::Config(format!(
641 "config file already exists: {} (remove it first or use --config)",
642 config_path.display()
643 )));
644 }
645 if bundle_path.exists() {
646 return Err(Error::Config(format!(
647 "secret bundle already exists: {} (remove it first or use --secret-bundle)",
648 bundle_path.display()
649 )));
650 }
651
652 let passphrase = if let Some(ref p) = args.secret_bundle_passphrase {
654 p.clone()
655 } else {
656 let mut bytes = [0u8; 32];
657 use rand::RngCore;
658 rand::rngs::OsRng.fill_bytes(&mut bytes);
659 STANDARD.encode(bytes)
660 };
661
662 let bundle = SecretBundle::generate();
664 bundle.save(&bundle_path, &passphrase)?;
665
666 let config = Config {
668 slug: slug.clone(),
669 log_level: DEFAULT_LOG_LEVEL.to_string(),
670 log_level_stdout: DEFAULT_LOG_LEVEL_STDOUT.to_string(),
671 log_file: None,
672 #[cfg(not(target_arch = "wasm32"))]
673 kubo_rpc_url: DEFAULT_KUBO_RPC_URL.to_string(),
674 #[cfg(not(target_arch = "wasm32"))]
675 kubo_key_alias: slug.clone(),
676 secret_bundle: Some(bundle_path.clone()),
677 secret_bundle_passphrase: Some(passphrase),
678 config_path: Some(config_path.clone()),
679 extra: serde_yaml::Mapping::new(),
680 };
681 config.save()?;
682
683 println!("Config: {}", config_path.display());
684 println!("Secret bundle: {}", bundle_path.display());
685
686 Ok(())
687 }
688}