1use std::fs::{self, File, OpenOptions};
2use std::io::{self, ErrorKind, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use anyhow::{anyhow, bail, Context, Result};
7use serde::Deserialize;
8
9use crate::config::{Settings, Signatory};
10use crate::secret::{SecretRef, SecretRefError};
11
12const DEFAULT_SECRETS_DIRECTORY: &str = "cdk-mintd-secrets";
13const TEMPORARY_FILE_ATTEMPTS: usize = 100;
14static TEMPORARY_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
15const RELEASED_V017_SIGNATORY_URL_ENV_VAR: &str = "CDK_MINTD_SIGNATORY_URL";
16const RELEASED_V017_SIGNATORY_CERTS_ENV_VAR: &str = "CDK_MINTD_SIGNATORY_CERTS";
17const RELEASED_V017_LN_BACKEND_ENV_VAR: &str = "CDK_MINTD_LN_BACKEND";
18const RELEASED_V017_LN_INVOICE_DESCRIPTION_ENV_VAR: &str = "CDK_MINTD_LN_INVOICE_DESCRIPTION";
19const RELEASED_V017_LN_MIN_MINT_ENV_VAR: &str = "CDK_MINTD_LN_MIN_MINT";
20const RELEASED_V017_LN_MAX_MINT_ENV_VAR: &str = "CDK_MINTD_LN_MAX_MINT";
21const RELEASED_V017_LN_MIN_MELT_ENV_VAR: &str = "CDK_MINTD_LN_MIN_MELT";
22const RELEASED_V017_LN_MAX_MELT_ENV_VAR: &str = "CDK_MINTD_LN_MAX_MELT";
23const RELEASED_V017_UNKNOWN_FIELDS: &[&str] = &[
24 "info.signatory_certs",
25 "info.signatory_url",
26 "ln",
27 #[cfg(feature = "management-rpc")]
28 "mint_management_rpc.tls_dir_path",
29];
30#[cfg(feature = "management-rpc")]
31const RELEASED_V017_MANAGEMENT_TLS_DIR_ENV_VAR: &str = "CDK_MINTD_MANAGEMENT_TLS_DIR_PATH";
32#[cfg(feature = "redis")]
33const ENV_CACHE_BACKEND: &str = "CDK_MINTD_CACHE_BACKEND";
34#[cfg(feature = "redis")]
35const ENV_CACHE_REDIS_URL: &str = "CDK_MINTD_CACHE_REDIS_URL";
36
37#[derive(Default, Deserialize)]
38#[serde(default)]
39struct ReleasedV017Document {
40 info: ReleasedV017Info,
41 signatory: Option<CanonicalSignatoryTable>,
42 mint_management_rpc: Option<ReleasedV017ManagementRpc>,
43 ln: ReleasedV017LnSection,
44}
45
46#[derive(Default, Deserialize)]
49#[serde(untagged)]
50enum ReleasedV017LnSection {
51 #[default]
52 Missing,
53 One(ReleasedV017Ln),
54 Many(Vec<ReleasedV017Ln>),
55}
56
57#[derive(Default, Deserialize)]
58#[serde(default)]
59struct ReleasedV017Ln {
60 ln_backend: Option<String>,
61 unit: Option<cdk::nuts::CurrencyUnit>,
62 invoice_description: Option<String>,
63 min_mint: Option<u64>,
64 max_mint: Option<u64>,
65 min_melt: Option<u64>,
66 max_melt: Option<u64>,
67}
68
69#[derive(Default)]
71struct ReleasedV017LnEnv {
72 backend: Option<String>,
73 invoice_description: Option<String>,
74 min_mint: Option<u64>,
75 max_mint: Option<u64>,
76 min_melt: Option<u64>,
77 max_melt: Option<u64>,
78}
79
80impl ReleasedV017LnEnv {
81 fn from_env() -> Self {
82 fn parse_amount(name: &str) -> Option<u64> {
83 std::env::var(name)
84 .ok()
85 .and_then(|value| match value.parse::<u64>() {
86 Ok(amount) => Some(amount),
87 Err(_) => {
88 tracing::warn!("Ignoring invalid amount in legacy env var {name}: {value}");
89 None
90 }
91 })
92 }
93
94 Self {
95 backend: std::env::var(RELEASED_V017_LN_BACKEND_ENV_VAR).ok(),
96 invoice_description: std::env::var(RELEASED_V017_LN_INVOICE_DESCRIPTION_ENV_VAR).ok(),
97 min_mint: parse_amount(RELEASED_V017_LN_MIN_MINT_ENV_VAR),
98 max_mint: parse_amount(RELEASED_V017_LN_MAX_MINT_ENV_VAR),
99 min_melt: parse_amount(RELEASED_V017_LN_MIN_MELT_ENV_VAR),
100 max_melt: parse_amount(RELEASED_V017_LN_MAX_MELT_ENV_VAR),
101 }
102 }
103
104 fn is_empty(&self) -> bool {
105 self.backend.is_none()
106 && self.invoice_description.is_none()
107 && self.min_mint.is_none()
108 && self.max_mint.is_none()
109 && self.min_melt.is_none()
110 && self.max_melt.is_none()
111 }
112}
113
114#[derive(Default, Deserialize)]
115#[serde(default)]
116struct ReleasedV017Info {
117 signatory_url: Option<String>,
118 signatory_certs: Option<String>,
119}
120
121#[derive(Default, Deserialize)]
122#[serde(default)]
123struct CanonicalSignatoryTable {}
124
125#[derive(Default, Deserialize)]
126#[serde(default)]
127struct ReleasedV017ManagementRpc {
128 tls_dir_path: Option<PathBuf>,
129 tls_dir: Option<PathBuf>,
130 allow_insecure: Option<bool>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct MigrationOutcome {
136 pub output: PathBuf,
138 pub secrets_dir: Option<PathBuf>,
140 pub secret_files_written: usize,
142}
143
144#[derive(Debug, Clone, Copy)]
145enum SecretNormalization {
146 Opaque,
147 Trim,
148}
149
150#[derive(Debug)]
151struct SecretFile {
152 path: PathBuf,
153 value: String,
154}
155
156#[derive(Debug)]
157struct StagedMigrationFile {
158 kind: &'static str,
159 destination: PathBuf,
160 staged: PathBuf,
161 backup: Option<PathBuf>,
162 published: bool,
163}
164
165#[derive(Debug)]
166struct MigrationSecrets {
167 directory: PathBuf,
168 manage_directory_permissions: bool,
169 files: Vec<SecretFile>,
170 protected_files: Vec<PathBuf>,
171}
172
173impl MigrationSecrets {
174 fn new(directory: PathBuf, manage_directory_permissions: bool) -> Self {
175 Self {
176 directory,
177 manage_directory_permissions,
178 files: Vec::new(),
179 protected_files: Vec::new(),
180 }
181 }
182
183 fn add(&mut self, name: &str, value: &str) -> String {
184 let path = self.directory.join(name);
185 self.files.push(SecretFile {
186 path: path.clone(),
187 value: value.to_owned(),
188 });
189 SecretRef::File(path).to_string()
190 }
191
192 fn protect(&mut self, path: PathBuf) {
193 if !self.protected_files.contains(&path) {
194 self.protected_files.push(path);
195 }
196 }
197}
198
199pub fn migrate_legacy_configuration(
206 source: &Path,
207 output: &Path,
208 secrets_dir: Option<&Path>,
209 legacy_seed_file: Option<&Path>,
210 force: bool,
211) -> Result<MigrationOutcome> {
212 let source = source.canonicalize().with_context(|| {
213 format!(
214 "could not resolve legacy configuration {}",
215 source.display()
216 )
217 })?;
218 let output = absolute_path(output)?;
219
220 let document = fs::read_to_string(&source)
221 .with_context(|| format!("could not read legacy configuration {}", source.display()))?;
222 let parse_context = format!("could not parse legacy configuration {}", source.display());
223 let released_v017 = released_v017_document(&document).with_context(|| parse_context.clone())?;
224 let mut effective = Settings::try_from_toml_allowing(&document, RELEASED_V017_UNKNOWN_FIELDS)
225 .with_context(|| parse_context)?;
226 apply_released_v017_payment_backend_file(&mut effective, &released_v017)?;
230 apply_released_v017_payment_backend_env(&mut effective)?;
235 effective = effective
236 .from_env()
237 .context("could not apply legacy environment overrides")?;
238
239 let legacy_seed_file = match legacy_seed_file {
240 Some(seed_file) => {
241 let seed_file = existing_absolute_path(seed_file)?;
242 crate::apply_seed_file(&mut effective, &seed_file)?;
243 Some(seed_file)
244 }
245 None => None,
246 };
247 let seed_reference = legacy_seed_file
248 .as_ref()
249 .map(|seed_file| SecretRef::File(seed_file.clone()).to_string());
250
251 apply_released_v017_compatibility(&mut effective, &released_v017)?;
252 crate::config_service::prune_inactive_configuration(&mut effective);
253 let mut migrated = effective.clone();
254 let mut resolved = effective;
255
256 let (secrets_dir, manage_directory_permissions) = match secrets_dir {
257 Some(secrets_dir) => (absolute_path(secrets_dir)?, false),
258 None => (
259 output
260 .parent()
261 .unwrap_or(Path::new("/"))
262 .join(DEFAULT_SECRETS_DIRECTORY),
263 true,
264 ),
265 };
266 let source_dir = source.parent().unwrap_or(Path::new("/"));
267 let mut secrets = MigrationSecrets::new(secrets_dir, manage_directory_permissions);
268 secrets.protect(source.clone());
269 if let Some(seed_file) = legacy_seed_file {
270 secrets.protect(seed_file);
271 }
272
273 externalize_optional_secret(
274 &mut migrated.info.seed,
275 &mut resolved.info.seed,
276 "mint-seed",
277 &[crate::env_vars::ENV_SEED],
278 None,
279 SecretNormalization::Opaque,
280 source_dir,
281 &mut secrets,
282 )?;
283 externalize_optional_secret(
284 &mut migrated.info.mnemonic,
285 &mut resolved.info.mnemonic,
286 "mint-mnemonic",
287 &[crate::env_vars::ENV_MNEMONIC],
288 seed_reference.as_deref(),
289 SecretNormalization::Trim,
290 source_dir,
291 &mut secrets,
292 )?;
293
294 if let (Some(migrated_postgres), Some(resolved_postgres)) = (
295 migrated.database.postgres.as_mut(),
296 resolved.database.postgres.as_mut(),
297 ) {
298 externalize_secret(
299 &mut migrated_postgres.url,
300 &mut resolved_postgres.url,
301 "postgres-url",
302 &[
303 crate::env_vars::ENV_POSTGRES_URL,
304 crate::env_vars::DATABASE_URL_ENV_VAR,
305 ],
306 None,
307 SecretNormalization::Opaque,
308 source_dir,
309 &mut secrets,
310 )?;
311 }
312
313 if let (Some(migrated_postgres), Some(resolved_postgres)) = (
314 migrated
315 .auth_database
316 .as_mut()
317 .and_then(|database| database.postgres.as_mut()),
318 resolved
319 .auth_database
320 .as_mut()
321 .and_then(|database| database.postgres.as_mut()),
322 ) {
323 externalize_secret(
324 &mut migrated_postgres.url,
325 &mut resolved_postgres.url,
326 "auth-postgres-url",
327 &[crate::env_vars::ENV_AUTH_POSTGRES_URL],
328 None,
329 SecretNormalization::Opaque,
330 source_dir,
331 &mut secrets,
332 )?;
333 }
334
335 #[cfg(feature = "bdk")]
336 if let (Some(migrated_bdk), Some(resolved_bdk)) = (migrated.bdk.as_mut(), resolved.bdk.as_mut())
337 {
338 externalize_optional_secret(
339 &mut migrated_bdk.bitcoind_rpc_password,
340 &mut resolved_bdk.bitcoind_rpc_password,
341 "bdk-bitcoind-rpc-password",
342 &[crate::env_vars::BDK_BITCOIND_RPC_PASSWORD_ENV_VAR],
343 None,
344 SecretNormalization::Opaque,
345 source_dir,
346 &mut secrets,
347 )?;
348 externalize_optional_secret(
349 &mut migrated_bdk.mnemonic,
350 &mut resolved_bdk.mnemonic,
351 "bdk-mnemonic",
352 &[crate::env_vars::BDK_MNEMONIC_ENV_VAR],
353 seed_reference.as_deref(),
354 SecretNormalization::Trim,
355 source_dir,
356 &mut secrets,
357 )?;
358 }
359
360 #[cfg(feature = "ldk-node")]
361 if let (Some(migrated_ldk), Some(resolved_ldk)) =
362 (migrated.ldk_node.as_mut(), resolved.ldk_node.as_mut())
363 {
364 externalize_optional_secret(
365 &mut migrated_ldk.bitcoind_rpc_password,
366 &mut resolved_ldk.bitcoind_rpc_password,
367 "ldk-node-bitcoind-rpc-password",
368 &[crate::env_vars::LDK_NODE_BITCOIND_RPC_PASSWORD_ENV_VAR],
369 None,
370 SecretNormalization::Opaque,
371 source_dir,
372 &mut secrets,
373 )?;
374 externalize_optional_secret(
375 &mut migrated_ldk.ldk_node_mnemonic,
376 &mut resolved_ldk.ldk_node_mnemonic,
377 "ldk-node-mnemonic",
378 &[crate::env_vars::LDK_NODE_MNEMONIC_ENV_VAR],
379 seed_reference.as_deref(),
380 SecretNormalization::Trim,
381 source_dir,
382 &mut secrets,
383 )?;
384 }
385
386 #[cfg(feature = "redis")]
387 if let (
388 cdk_axum::cache::Backend::Redis(migrated_redis),
389 cdk_axum::cache::Backend::Redis(resolved_redis),
390 ) = (
391 &mut migrated.info.http_cache.backend,
392 &mut resolved.info.http_cache.backend,
393 ) {
394 let connection_env_names = std::env::var(ENV_CACHE_BACKEND)
395 .is_ok_and(|backend| backend.eq_ignore_ascii_case("redis"))
396 .then_some([ENV_CACHE_REDIS_URL]);
397 externalize_secret(
398 &mut migrated_redis.connection_string,
399 &mut resolved_redis.connection_string,
400 "redis-connection-string",
401 connection_env_names.as_ref().map_or(&[], |names| names),
402 None,
403 SecretNormalization::Opaque,
404 source_dir,
405 &mut secrets,
406 )?;
407 if let (Some(migrated_nodes), Some(resolved_nodes)) = (
408 migrated_redis.cluster_nodes.as_mut(),
409 resolved_redis.cluster_nodes.as_mut(),
410 ) {
411 for (index, (migrated_node, resolved_node)) in migrated_nodes
412 .iter_mut()
413 .zip(resolved_nodes.iter_mut())
414 .enumerate()
415 {
416 externalize_secret(
417 migrated_node,
418 resolved_node,
419 &format!("redis-cluster-node-{}", index + 1),
420 &[],
421 None,
422 SecretNormalization::Opaque,
423 source_dir,
424 &mut secrets,
425 )?;
426 }
427 }
428 }
429
430 crate::validate_settings(&resolved).context("legacy effective configuration is invalid")?;
431 let migrated_document =
432 toml::to_string_pretty(&migrated).context("could not serialize migrated configuration")?;
433 Settings::try_from_toml(&migrated_document)
434 .context("generated configuration did not round-trip through the TOML parser")?;
435
436 prepare_write_destinations(&output, &secrets, force)?;
437 if force {
438 write_forced_migration(&output, &migrated_document, &secrets)?;
439 } else {
440 write_secret_files(&secrets, force)?;
441 if let Err(error) = write_output(&output, &migrated_document, force) {
442 remove_generated_secrets(&secrets);
443 return Err(error);
444 }
445 }
446
447 Ok(MigrationOutcome {
448 output,
449 secrets_dir: (!secrets.files.is_empty()).then_some(secrets.directory),
450 secret_files_written: secrets.files.len(),
451 })
452}
453
454fn released_v017_document(document: &str) -> Result<ReleasedV017Document> {
455 Ok(toml::from_str(document)?)
458}
459
460fn apply_released_v017_compatibility(
461 settings: &mut Settings,
462 released: &ReleasedV017Document,
463) -> Result<()> {
464 apply_released_v017_signatory(settings, released)?;
465 #[cfg(feature = "management-rpc")]
466 apply_released_v017_management_rpc(settings, released);
467 Ok(())
468}
469
470fn apply_released_v017_payment_backend_file(
476 settings: &mut Settings,
477 released: &ReleasedV017Document,
478) -> Result<()> {
479 let legacy_entries: Vec<&ReleasedV017Ln> = match &released.ln {
480 ReleasedV017LnSection::Missing => Vec::new(),
481 ReleasedV017LnSection::One(ln) => vec![ln],
482 ReleasedV017LnSection::Many(lns) => lns.iter().collect(),
483 };
484
485 if !legacy_entries.is_empty() {
486 if settings.payment_backend.is_empty() {
487 for legacy in legacy_entries {
488 settings
489 .payment_backend
490 .push(convert_released_v017_ln(legacy)?);
491 }
492 } else {
493 tracing::warn!(
494 "Ignoring legacy [ln] section: canonical [payment_backend] section is present"
495 );
496 }
497 }
498
499 Ok(())
500}
501
502fn convert_released_v017_ln(legacy: &ReleasedV017Ln) -> Result<crate::config::PaymentBackend> {
503 let backend = match legacy.ln_backend.as_deref() {
504 None => crate::config::PaymentBackendType::None,
505 Some(value) => parse_released_v017_backend(value, "ln_backend")?,
506 };
507
508 Ok(crate::config::PaymentBackend {
509 backend,
510 unit: legacy.unit.clone().unwrap_or_default(),
511 invoice_description: legacy.invoice_description.clone(),
512 min_mint: legacy.min_mint.unwrap_or(1).into(),
513 max_mint: legacy.max_mint.unwrap_or(500_000).into(),
514 min_melt: legacy.min_melt.unwrap_or(1).into(),
515 max_melt: legacy.max_melt.unwrap_or(500_000).into(),
516 })
517}
518
519fn parse_released_v017_backend(
523 value: &str,
524 source: &str,
525) -> Result<crate::config::PaymentBackendType> {
526 if value.eq_ignore_ascii_case("none") {
527 return Ok(crate::config::PaymentBackendType::None);
528 }
529 value.parse().map_err(|err: String| {
530 anyhow!("legacy {source} value {value:?} is not a supported payment backend: {err}")
531 })
532}
533
534fn apply_released_v017_payment_backend_env(settings: &mut Settings) -> Result<()> {
540 let legacy = ReleasedV017LnEnv::from_env();
541 if legacy.is_empty() {
542 return Ok(());
543 }
544
545 let apply = |entry: &mut crate::config::PaymentBackend| -> Result<()> {
546 if let Some(backend) = legacy.backend.as_deref() {
547 entry.backend = parse_released_v017_backend(backend, RELEASED_V017_LN_BACKEND_ENV_VAR)?;
548 }
549 if let Some(description) = legacy.invoice_description.clone() {
550 entry.invoice_description = Some(description);
551 }
552 if let Some(min_mint) = legacy.min_mint {
553 entry.min_mint = min_mint.into();
554 }
555 if let Some(max_mint) = legacy.max_mint {
556 entry.max_mint = max_mint.into();
557 }
558 if let Some(min_melt) = legacy.min_melt {
559 entry.min_melt = min_melt.into();
560 }
561 if let Some(max_melt) = legacy.max_melt {
562 entry.max_melt = max_melt.into();
563 }
564 Ok(())
565 };
566
567 match settings.payment_backend.len() {
568 0 => {
569 let mut entry = crate::config::PaymentBackend::default();
570 apply(&mut entry)?;
571 if entry.backend != crate::config::PaymentBackendType::None {
572 settings.payment_backend.push(entry);
573 }
574 }
575 1 => apply(&mut settings.payment_backend[0])?,
576 _ => {
577 tracing::warn!(
578 "CDK_MINTD_LN_* legacy environment variables ignored: multiple [[payment_backend]] entries configured"
579 );
580 }
581 }
582
583 Ok(())
584}
585
586fn apply_released_v017_signatory(
587 settings: &mut Settings,
588 released: &ReleasedV017Document,
589) -> Result<()> {
590 let canonical_environment = [
591 crate::env_vars::ENV_SIGNATORY_ENABLED,
592 crate::env_vars::ENV_SIGNATORY_ADDRESS,
593 crate::env_vars::ENV_SIGNATORY_PORT,
594 crate::env_vars::ENV_SIGNATORY_TLS_DIR,
595 crate::env_vars::ENV_SIGNATORY_ALLOW_INSECURE,
596 ]
597 .iter()
598 .any(|name| std::env::var_os(name).is_some());
599 if released.signatory.is_some() || canonical_environment {
600 return Ok(());
601 }
602
603 let signatory_url = std::env::var(RELEASED_V017_SIGNATORY_URL_ENV_VAR)
604 .ok()
605 .or_else(|| released.info.signatory_url.clone());
606 let Some(signatory_url) = signatory_url else {
607 return Ok(());
608 };
609 let (address, port) = parse_released_v017_signatory_url(&signatory_url)?;
610 let tls_dir = std::env::var(RELEASED_V017_SIGNATORY_CERTS_ENV_VAR)
611 .ok()
612 .or_else(|| released.info.signatory_certs.clone())
613 .filter(|path| !path.is_empty())
614 .map(PathBuf::from);
615
616 settings.signatory = Some(Signatory {
617 enabled: true,
618 address,
619 port,
620 allow_insecure: tls_dir.is_none(),
621 tls_dir,
622 });
623 settings.info.seed = None;
627 settings.info.mnemonic = None;
628 Ok(())
629}
630
631fn parse_released_v017_signatory_url(url: &str) -> Result<(String, u16)> {
632 let (scheme, authority_with_path) = url.split_once("://").ok_or_else(|| {
633 anyhow!("released v0.17 signatory URL {url:?} must include an http:// or https:// scheme")
634 })?;
635 let default_port = match scheme {
636 "http" => 80,
637 "https" => 443,
638 _ => bail!("released v0.17 signatory URL {url:?} uses unsupported scheme {scheme:?}"),
639 };
640 let authority = authority_with_path
643 .strip_suffix('/')
644 .unwrap_or(authority_with_path);
645 if authority.is_empty()
646 || authority.contains(['/', '?', '#', '@'])
647 || authority.chars().any(char::is_whitespace)
648 {
649 bail!("released v0.17 signatory URL {url:?} is not a supported authority URL");
650 }
651
652 if let Some(ipv6) = authority.strip_prefix('[') {
653 let end = ipv6.find(']').ok_or_else(|| {
654 anyhow!("released v0.17 signatory URL {url:?} has an invalid IPv6 address")
655 })?;
656 let host = &ipv6[..end];
657 if host.is_empty() {
658 bail!("released v0.17 signatory URL {url:?} has an empty host");
659 }
660 let suffix = &ipv6[end + 1..];
661 let port = match suffix.strip_prefix(':') {
662 Some(port) => parse_released_v017_signatory_port(url, port)?,
663 None if suffix.is_empty() => default_port,
664 None => bail!("released v0.17 signatory URL {url:?} has an invalid authority"),
665 };
666 return Ok((format!("[{host}]"), port));
667 }
668
669 let (host, port) = match authority.rsplit_once(':') {
670 Some((host, port)) => (host, parse_released_v017_signatory_port(url, port)?),
671 None => (authority, default_port),
672 };
673 if host.is_empty() || host.contains(':') {
674 bail!("released v0.17 signatory URL {url:?} has an invalid host");
675 }
676 Ok((host.to_owned(), port))
677}
678
679fn parse_released_v017_signatory_port(url: &str, port: &str) -> Result<u16> {
680 port.parse::<u16>()
681 .with_context(|| format!("released v0.17 signatory URL {url:?} has an invalid port"))
682}
683
684#[cfg(feature = "management-rpc")]
685fn apply_released_v017_management_rpc(settings: &mut Settings, released: &ReleasedV017Document) {
686 let Some(management_rpc) = settings.mint_management_rpc.as_mut() else {
687 return;
688 };
689 let released_management = released.mint_management_rpc.as_ref();
690 let canonical_tls_authored = released_management
691 .and_then(|rpc| rpc.tls_dir.as_ref())
692 .is_some()
693 || std::env::var_os(crate::env_vars::ENV_MINT_MANAGEMENT_TLS_DIR).is_some();
694 if !canonical_tls_authored {
695 management_rpc.tls_dir = std::env::var(RELEASED_V017_MANAGEMENT_TLS_DIR_ENV_VAR)
696 .ok()
697 .map(PathBuf::from)
698 .or_else(|| released_management.and_then(|rpc| rpc.tls_dir_path.clone()));
699 }
700
701 let canonical_security_authored = released_management
702 .and_then(|rpc| rpc.allow_insecure)
703 .is_some()
704 || std::env::var_os(crate::env_vars::ENV_MINT_MANAGEMENT_ALLOW_INSECURE).is_some();
705 let released_environment_enabled =
706 std::env::var_os(crate::env_vars::ENV_MINT_MANAGEMENT_ENABLED).is_some()
707 || std::env::var_os(crate::env_vars::ENV_MINT_MANAGEMENT_ENABLED_LEGACY).is_some();
708 let released_table = released_management
709 .is_some_and(|rpc| rpc.tls_dir.is_none() && rpc.allow_insecure.is_none());
710 if management_rpc.enabled
711 && management_rpc.tls_dir.is_none()
712 && !canonical_security_authored
713 && (released_table || released_environment_enabled)
714 {
715 management_rpc.allow_insecure = true;
718 }
719}
720
721#[allow(clippy::too_many_arguments)]
722fn externalize_optional_secret(
723 migrated: &mut Option<String>,
724 resolved: &mut Option<String>,
725 file_name: &str,
726 env_names: &[&str],
727 preferred_reference: Option<&str>,
728 normalization: SecretNormalization,
729 source_dir: &Path,
730 secrets: &mut MigrationSecrets,
731) -> Result<()> {
732 match (migrated.as_mut(), resolved.as_mut()) {
733 (Some(migrated), Some(resolved)) => externalize_secret(
734 migrated,
735 resolved,
736 file_name,
737 env_names,
738 preferred_reference,
739 normalization,
740 source_dir,
741 secrets,
742 ),
743 _ => Ok(()),
744 }
745}
746
747#[allow(clippy::too_many_arguments)]
748fn externalize_secret(
749 migrated: &mut String,
750 resolved: &mut String,
751 file_name: &str,
752 env_names: &[&str],
753 preferred_reference: Option<&str>,
754 normalization: SecretNormalization,
755 source_dir: &Path,
756 secrets: &mut MigrationSecrets,
757) -> Result<()> {
758 if let Some(reference) = preferred_reference {
759 *migrated = reference.to_owned();
760 normalize_secret(resolved, normalization);
761 return Ok(());
762 }
763
764 if let Some(name) = env_names.iter().find(|name| std::env::var(name).is_ok()) {
765 if resolved.is_empty() {
766 bail!("environment-backed secret {name} is empty");
767 }
768 *migrated = SecretRef::Environment((*name).to_owned()).to_string();
769 normalize_secret(resolved, normalization);
770 return Ok(());
771 }
772
773 match SecretRef::parse_relative_to(migrated, source_dir) {
774 Ok(reference) => {
775 let canonical = reference.to_string();
776 *resolved = reference.resolve().map_err(|error| match &error {
777 crate::secret::SecretResolveError::Environment { name } => {
778 anyhow!("could not resolve {file_name} from environment {name}")
779 }
780 crate::secret::SecretResolveError::File { path, source } => {
781 anyhow!(
782 "could not resolve {file_name} from {}: {source}",
783 path.display()
784 )
785 }
786 })?;
787 if resolved.is_empty() {
788 bail!("secret reference {canonical} resolved to an empty value");
789 }
790 if let SecretRef::File(path) = &reference {
791 secrets.protect(path.canonicalize().with_context(|| {
792 format!(
793 "could not resolve referenced secret file {}",
794 path.display()
795 )
796 })?);
797 }
798 *migrated = canonical;
799 normalize_secret(resolved, normalization);
800 return Ok(());
801 }
802 Err(SecretRefError::EmptyEnvironmentName) => {
803 bail!("empty environment secret reference for {file_name}")
804 }
805 Err(SecretRefError::EmptyFilePath) => {
806 bail!("empty file secret reference for {file_name}")
807 }
808 Err(SecretRefError::Literal | SecretRefError::RelativeFilePath { .. }) => {}
809 }
810
811 if migrated.is_empty() {
812 return Ok(());
813 }
814
815 normalize_secret(resolved, normalization);
816 *migrated = secrets.add(file_name, migrated);
817 Ok(())
818}
819
820fn normalize_secret(value: &mut String, normalization: SecretNormalization) {
821 if matches!(normalization, SecretNormalization::Trim) {
822 *value = value.trim().to_owned();
823 }
824}
825
826fn absolute_path(path: &Path) -> Result<PathBuf> {
827 if path.is_absolute() {
828 Ok(path.to_owned())
829 } else {
830 Ok(std::env::current_dir()
831 .context("could not determine current directory")?
832 .join(path))
833 }
834}
835
836fn existing_absolute_path(path: &Path) -> Result<PathBuf> {
837 path.canonicalize()
838 .with_context(|| format!("could not resolve {}", path.display()))
839}
840
841fn prepare_write_destinations(
842 output: &Path,
843 secrets: &MigrationSecrets,
844 force: bool,
845) -> Result<()> {
846 let output_destination = (
847 "migration output",
848 output,
849 resolve_destination(output, "migration output")?,
850 );
851 ensure_distinct_write_destinations(
852 std::slice::from_ref(&output_destination),
853 &secrets.protected_files,
854 )?;
855 ensure_replaceable(output, force, "migration output")?;
856
857 if !secrets.files.is_empty() {
858 prepare_secrets_directory(&secrets.directory, secrets.manage_directory_permissions)?;
859 }
860
861 let mut destinations = Vec::with_capacity(secrets.files.len() + 1);
862 destinations.push(output_destination);
863 for secret in &secrets.files {
864 destinations.push((
865 "secret file",
866 secret.path.as_path(),
867 resolve_destination(&secret.path, "secret file")?,
868 ));
869 }
870
871 ensure_distinct_write_destinations(&destinations, &secrets.protected_files)?;
872 for (kind, path, _) in destinations.into_iter().skip(1) {
873 ensure_replaceable(path, force, kind)?;
874 }
875
876 Ok(())
877}
878
879fn resolve_destination(path: &Path, kind: &str) -> Result<PathBuf> {
880 let parent = path
881 .parent()
882 .ok_or_else(|| anyhow!("{kind} {} has no parent directory", path.display()))?;
883 let file_name = path
884 .file_name()
885 .ok_or_else(|| anyhow!("{kind} {} is not a file path", path.display()))?;
886 fs::create_dir_all(parent)
887 .with_context(|| format!("could not create {kind} directory {}", parent.display()))?;
888 let parent = parent
889 .canonicalize()
890 .with_context(|| format!("could not resolve {kind} directory {}", parent.display()))?;
891 Ok(parent.join(file_name))
892}
893
894fn ensure_distinct_write_destinations(
895 destinations: &[(&str, &Path, PathBuf)],
896 protected_files: &[PathBuf],
897) -> Result<()> {
898 for (kind, path, resolved) in destinations {
899 for protected in protected_files {
900 if resolved == protected || same_file(path, protected)? {
901 bail!(
902 "{kind} {} must differ from input file {}",
903 path.display(),
904 protected.display()
905 );
906 }
907 }
908 }
909
910 for (index, (kind, path, resolved)) in destinations.iter().enumerate() {
911 for (other_kind, other_path, other_resolved) in destinations.iter().skip(index + 1) {
912 if resolved == other_resolved || same_file(path, other_path)? {
913 bail!(
914 "{kind} {} must differ from {other_kind} {}",
915 path.display(),
916 other_path.display()
917 );
918 }
919 }
920 }
921
922 Ok(())
923}
924
925#[cfg(unix)]
926fn same_file(left: &Path, right: &Path) -> Result<bool> {
927 use std::os::unix::fs::MetadataExt;
928
929 if !left.exists() || !right.exists() {
930 return Ok(false);
931 }
932 let left = fs::metadata(left)
933 .with_context(|| format!("could not inspect source {}", left.display()))?;
934 let right = fs::metadata(right)
935 .with_context(|| format!("could not inspect output {}", right.display()))?;
936 Ok(left.dev() == right.dev() && left.ino() == right.ino())
937}
938
939#[cfg(not(unix))]
940fn same_file(_left: &Path, _right: &Path) -> Result<bool> {
941 Ok(false)
942}
943
944fn ensure_replaceable(path: &Path, force: bool, kind: &str) -> Result<()> {
945 if let Ok(metadata) = fs::symlink_metadata(path) {
946 if metadata.file_type().is_symlink() {
947 bail!("{kind} {} must not be a symbolic link", path.display());
948 }
949 if !metadata.is_file() {
950 bail!("{kind} {} must be a regular file", path.display());
951 }
952 if has_multiple_hard_links(&metadata) {
953 bail!(
954 "{kind} {} must not have multiple hard links",
955 path.display()
956 );
957 }
958 if !force {
959 bail!(
960 "{kind} {} already exists; pass --force to overwrite it",
961 path.display()
962 );
963 }
964 }
965 Ok(())
966}
967
968#[cfg(unix)]
969fn has_multiple_hard_links(metadata: &fs::Metadata) -> bool {
970 use std::os::unix::fs::MetadataExt;
971
972 metadata.nlink() > 1
973}
974
975#[cfg(not(unix))]
976fn has_multiple_hard_links(_metadata: &fs::Metadata) -> bool {
977 false
978}
979
980fn prepare_secrets_directory(path: &Path, manage_permissions: bool) -> Result<()> {
981 match fs::symlink_metadata(path) {
982 Ok(metadata) => {
983 validate_existing_secrets_directory(path, &metadata)?;
984 if manage_permissions {
985 set_directory_permissions(path)?;
986 }
987 Ok(())
988 }
989 Err(error) if error.kind() == ErrorKind::NotFound => {
990 let parent = path.parent().ok_or_else(|| {
991 anyhow!(
992 "secrets directory {} has no parent directory",
993 path.display()
994 )
995 })?;
996 fs::create_dir_all(parent).with_context(|| {
997 format!(
998 "could not create secrets directory parent {}",
999 parent.display()
1000 )
1001 })?;
1002
1003 match create_secret_directory(path) {
1004 Ok(()) => set_directory_permissions(path),
1005 Err(error) if error.kind() == ErrorKind::AlreadyExists => {
1006 let metadata = fs::symlink_metadata(path).with_context(|| {
1007 format!("could not inspect secrets directory {}", path.display())
1008 })?;
1009 validate_existing_secrets_directory(path, &metadata)?;
1010 if manage_permissions {
1011 set_directory_permissions(path)?;
1012 }
1013 Ok(())
1014 }
1015 Err(error) => Err(error).with_context(|| {
1016 format!("could not create secrets directory {}", path.display())
1017 }),
1018 }
1019 }
1020 Err(error) => Err(error)
1021 .with_context(|| format!("could not inspect secrets directory {}", path.display())),
1022 }
1023}
1024
1025fn validate_existing_secrets_directory(path: &Path, metadata: &fs::Metadata) -> Result<()> {
1026 if metadata.file_type().is_symlink() {
1027 bail!(
1028 "secrets directory {} must not be a symbolic link",
1029 path.display()
1030 );
1031 }
1032 if !metadata.is_dir() {
1033 bail!("secrets directory {} is not a directory", path.display());
1034 }
1035 Ok(())
1036}
1037
1038fn create_secret_directory(path: &Path) -> io::Result<()> {
1039 let mut builder = fs::DirBuilder::new();
1040 #[cfg(unix)]
1041 {
1042 use std::os::unix::fs::DirBuilderExt;
1043
1044 builder.mode(0o700);
1045 }
1046 builder.create(path)
1047}
1048
1049fn write_forced_migration(output: &Path, document: &str, secrets: &MigrationSecrets) -> Result<()> {
1050 let mut staged = Vec::with_capacity(secrets.files.len() + 1);
1051 for secret in &secrets.files {
1052 match stage_migration_file("secret file", &secret.path, &secret.value, true) {
1053 Ok(file) => staged.push(file),
1054 Err(error) => {
1055 remove_staged_files(&staged);
1056 return Err(error);
1057 }
1058 }
1059 }
1060 match stage_migration_file("migration output", output, document, false) {
1061 Ok(file) => staged.push(file),
1062 Err(error) => {
1063 remove_staged_files(&staged);
1064 return Err(error);
1065 }
1066 }
1067
1068 commit_staged_files(&mut staged)
1069}
1070
1071fn stage_migration_file(
1072 kind: &'static str,
1073 destination: &Path,
1074 contents: &str,
1075 secret: bool,
1076) -> Result<StagedMigrationFile> {
1077 let (staged, mut file) = create_temporary_sibling(destination, "staged", secret)?;
1078 let write_result = (|| -> Result<()> {
1079 file.write_all(contents.as_bytes())
1080 .with_context(|| format!("could not stage {kind} {}", destination.display()))?;
1081 if secret {
1082 set_secret_file_permissions(&staged)?;
1083 }
1084 file.sync_all()
1085 .with_context(|| format!("could not sync staged {kind} {}", destination.display()))?;
1086 Ok(())
1087 })();
1088 drop(file);
1089
1090 if let Err(error) = write_result {
1091 let _ = fs::remove_file(&staged);
1092 return Err(error);
1093 }
1094
1095 Ok(StagedMigrationFile {
1096 kind,
1097 destination: destination.to_owned(),
1098 staged,
1099 backup: None,
1100 published: false,
1101 })
1102}
1103
1104fn create_temporary_sibling(
1105 destination: &Path,
1106 role: &str,
1107 secret: bool,
1108) -> Result<(PathBuf, File)> {
1109 let parent = destination.parent().ok_or_else(|| {
1110 anyhow!(
1111 "migration destination {} has no parent directory",
1112 destination.display()
1113 )
1114 })?;
1115
1116 for _ in 0..TEMPORARY_FILE_ATTEMPTS {
1117 let counter = TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
1118 let path = parent.join(format!(
1119 ".cdk-mintd-migration-{role}-{}-{counter}",
1120 std::process::id()
1121 ));
1122 let mut options = OpenOptions::new();
1123 options.write(true).create_new(true);
1124 if secret {
1125 set_secret_creation_mode(&mut options);
1126 }
1127 match options.open(&path) {
1128 Ok(file) => return Ok((path, file)),
1129 Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
1130 Err(error) => {
1131 return Err(error).with_context(|| {
1132 format!(
1133 "could not create temporary file beside {}",
1134 destination.display()
1135 )
1136 });
1137 }
1138 }
1139 }
1140
1141 bail!(
1142 "could not allocate a temporary file beside {}",
1143 destination.display()
1144 )
1145}
1146
1147fn unused_temporary_sibling(destination: &Path, role: &str) -> Result<PathBuf> {
1148 let parent = destination.parent().ok_or_else(|| {
1149 anyhow!(
1150 "migration destination {} has no parent directory",
1151 destination.display()
1152 )
1153 })?;
1154
1155 for _ in 0..TEMPORARY_FILE_ATTEMPTS {
1156 let counter = TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
1157 let path = parent.join(format!(
1158 ".cdk-mintd-migration-{role}-{}-{counter}",
1159 std::process::id()
1160 ));
1161 match fs::symlink_metadata(&path) {
1162 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(path),
1163 Ok(_) => {}
1164 Err(error) => {
1165 return Err(error).with_context(|| {
1166 format!(
1167 "could not inspect temporary path beside {}",
1168 destination.display()
1169 )
1170 });
1171 }
1172 }
1173 }
1174
1175 bail!(
1176 "could not allocate a temporary path beside {}",
1177 destination.display()
1178 )
1179}
1180
1181fn commit_staged_files(staged: &mut [StagedMigrationFile]) -> Result<()> {
1182 if let Err(error) = backup_existing_files(staged) {
1183 let rollback_errors = rollback_staged_files(staged);
1184 remove_staged_files(staged);
1185 return Err(with_rollback_context(error, rollback_errors));
1186 }
1187
1188 for index in 0..staged.len() {
1189 let file = &staged[index];
1190 if let Err(error) = fs::rename(&file.staged, &file.destination).with_context(|| {
1191 format!(
1192 "could not publish {} {}",
1193 file.kind,
1194 file.destination.display()
1195 )
1196 }) {
1197 let rollback_errors = rollback_staged_files(staged);
1198 remove_staged_files(staged);
1199 return Err(with_rollback_context(error, rollback_errors));
1200 }
1201 staged[index].published = true;
1202 }
1203
1204 remove_backup_files(staged)
1205}
1206
1207fn backup_existing_files(staged: &mut [StagedMigrationFile]) -> Result<()> {
1208 for file in staged {
1209 match fs::symlink_metadata(&file.destination) {
1210 Ok(_) => {
1211 ensure_replaceable(&file.destination, true, file.kind)?;
1212 let backup = unused_temporary_sibling(&file.destination, "backup")?;
1213 fs::rename(&file.destination, &backup).with_context(|| {
1214 format!(
1215 "could not back up {} {}",
1216 file.kind,
1217 file.destination.display()
1218 )
1219 })?;
1220 file.backup = Some(backup);
1221 }
1222 Err(error) if error.kind() == ErrorKind::NotFound => {}
1223 Err(error) => {
1224 return Err(error).with_context(|| {
1225 format!(
1226 "could not inspect {} {} before publishing",
1227 file.kind,
1228 file.destination.display()
1229 )
1230 });
1231 }
1232 }
1233 }
1234 Ok(())
1235}
1236
1237fn rollback_staged_files(staged: &mut [StagedMigrationFile]) -> Vec<String> {
1238 let mut errors = Vec::new();
1239 for file in staged.iter_mut().rev() {
1240 if file.published {
1241 match fs::remove_file(&file.destination) {
1242 Ok(()) => file.published = false,
1243 Err(error) => {
1244 errors.push(format!(
1245 "could not remove newly published {} {}: {error}",
1246 file.kind,
1247 file.destination.display()
1248 ));
1249 continue;
1250 }
1251 }
1252 }
1253 if let Some(backup) = file.backup.take() {
1254 if let Err(error) = fs::rename(&backup, &file.destination) {
1255 errors.push(format!(
1256 "could not restore {} {} from {}: {error}",
1257 file.kind,
1258 file.destination.display(),
1259 backup.display()
1260 ));
1261 file.backup = Some(backup);
1262 }
1263 }
1264 }
1265 errors
1266}
1267
1268fn with_rollback_context(error: anyhow::Error, rollback_errors: Vec<String>) -> anyhow::Error {
1269 if rollback_errors.is_empty() {
1270 error
1271 } else {
1272 anyhow!(
1273 "{error:#}; migration rollback also failed: {}",
1274 rollback_errors.join("; ")
1275 )
1276 }
1277}
1278
1279fn remove_staged_files(staged: &[StagedMigrationFile]) {
1280 for file in staged {
1281 let _ = fs::remove_file(&file.staged);
1282 }
1283}
1284
1285fn remove_backup_files(staged: &mut [StagedMigrationFile]) -> Result<()> {
1286 for file in staged {
1287 if let Some(backup) = file.backup.take() {
1288 if let Err(error) = fs::remove_file(&backup) {
1289 file.backup = Some(backup.clone());
1290 return Err(error).with_context(|| {
1291 format!(
1292 "migration files were published, but the backup {} could not be removed",
1293 backup.display()
1294 )
1295 });
1296 }
1297 }
1298 }
1299 Ok(())
1300}
1301
1302fn write_secret_files(secrets: &MigrationSecrets, force: bool) -> Result<()> {
1303 if secrets.files.is_empty() {
1304 return Ok(());
1305 }
1306
1307 let metadata = fs::symlink_metadata(&secrets.directory).with_context(|| {
1308 format!(
1309 "could not inspect secrets directory {}",
1310 secrets.directory.display()
1311 )
1312 })?;
1313 validate_existing_secrets_directory(&secrets.directory, &metadata)?;
1314
1315 let mut written = Vec::new();
1316 for secret in &secrets.files {
1317 let result = write_secret_file(secret, force);
1318 match result {
1319 Ok(()) => written.push(secret.path.clone()),
1320 Err(error) => {
1321 if !force {
1322 for path in written {
1323 let _ = fs::remove_file(path);
1324 }
1325 }
1326 return Err(error);
1327 }
1328 }
1329 }
1330 Ok(())
1331}
1332
1333fn write_secret_file(secret: &SecretFile, force: bool) -> Result<()> {
1334 let mut options = OpenOptions::new();
1335 options.write(true);
1336 if force {
1337 options.create(true).truncate(true);
1338 } else {
1339 options.create_new(true);
1340 }
1341 set_secret_creation_mode(&mut options);
1342 let mut file = options
1343 .open(&secret.path)
1344 .with_context(|| format!("could not create secret file {}", secret.path.display()))?;
1345 let write_result = (|| -> Result<()> {
1346 file.write_all(secret.value.as_bytes())
1347 .with_context(|| format!("could not write secret file {}", secret.path.display()))?;
1348 set_secret_file_permissions(&secret.path)
1349 })();
1350 drop(file);
1351
1352 if write_result.is_err() && !force {
1353 let _ = fs::remove_file(&secret.path);
1357 }
1358 write_result
1359}
1360
1361fn remove_generated_secrets(secrets: &MigrationSecrets) {
1362 for secret in &secrets.files {
1363 let _ = fs::remove_file(&secret.path);
1364 }
1365}
1366
1367fn write_output(path: &Path, document: &str, force: bool) -> Result<()> {
1368 let parent = path.parent().ok_or_else(|| {
1369 anyhow!(
1370 "migration output {} has no parent directory",
1371 path.display()
1372 )
1373 })?;
1374 fs::create_dir_all(parent)
1375 .with_context(|| format!("could not create output directory {}", parent.display()))?;
1376
1377 let mut options = OpenOptions::new();
1378 options.write(true);
1379 if force {
1380 options.create(true).truncate(true);
1381 } else {
1382 options.create_new(true);
1383 }
1384 let mut file = options
1385 .open(path)
1386 .with_context(|| format!("could not create migration output {}", path.display()))?;
1387 if let Err(error) = file
1388 .write_all(document.as_bytes())
1389 .with_context(|| format!("could not write migration output {}", path.display()))
1390 {
1391 if !force {
1392 let _ = fs::remove_file(path);
1393 }
1394 return Err(error);
1395 }
1396 Ok(())
1397}
1398
1399#[cfg(unix)]
1400fn set_secret_creation_mode(options: &mut OpenOptions) {
1401 use std::os::unix::fs::OpenOptionsExt;
1402
1403 options.mode(0o600);
1404}
1405
1406#[cfg(not(unix))]
1407fn set_secret_creation_mode(_options: &mut OpenOptions) {}
1408
1409#[cfg(unix)]
1410fn set_directory_permissions(path: &Path) -> Result<()> {
1411 use std::os::unix::fs::PermissionsExt;
1412
1413 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1414 .with_context(|| format!("could not secure secrets directory {}", path.display()))
1415}
1416
1417#[cfg(not(unix))]
1418fn set_directory_permissions(_path: &Path) -> Result<()> {
1419 Ok(())
1420}
1421
1422#[cfg(unix)]
1423fn set_secret_file_permissions(path: &Path) -> Result<()> {
1424 use std::os::unix::fs::PermissionsExt;
1425
1426 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1427 .with_context(|| format!("could not secure secret file {}", path.display()))
1428}
1429
1430#[cfg(not(unix))]
1431fn set_secret_file_permissions(_path: &Path) -> Result<()> {
1432 Ok(())
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use std::ffi::OsString;
1438
1439 use super::*;
1440
1441 const TEST_MNEMONIC: &str =
1442 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1443
1444 struct MintdEnvironment {
1445 saved: Vec<(OsString, OsString)>,
1446 }
1447
1448 impl MintdEnvironment {
1449 fn cleared() -> Self {
1450 let saved = std::env::vars_os()
1451 .filter(|(name, _)| name.to_string_lossy().starts_with("CDK_MINTD_"))
1452 .collect::<Vec<_>>();
1453 for (name, _) in &saved {
1454 std::env::remove_var(name);
1455 }
1456 Self { saved }
1457 }
1458 }
1459
1460 impl Drop for MintdEnvironment {
1461 fn drop(&mut self) {
1462 for (name, _) in std::env::vars_os()
1463 .filter(|(name, _)| name.to_string_lossy().starts_with("CDK_MINTD_"))
1464 {
1465 std::env::remove_var(name);
1466 }
1467 for (name, value) in &self.saved {
1468 std::env::set_var(name, value);
1469 }
1470 }
1471 }
1472
1473 fn legacy_document(mnemonic: &str) -> String {
1474 format!(
1475 r#"
1476[info]
1477mnemonic = "{mnemonic}"
1478
1479[ln]
1480ln_backend = "fakewallet"
1481
1482[fake_wallet]
1483
1484[database]
1485engine = "sqlite"
1486"#
1487 )
1488 }
1489
1490 fn released_v017_remote_signatory_document(url: &str, certs: Option<&str>) -> String {
1491 let certs = certs
1492 .map(|certs| format!("signatory_certs = \"{certs}\""))
1493 .unwrap_or_default();
1494 format!(
1495 r#"
1496[info]
1497signatory_url = "{url}"
1498{certs}
1499
1500[ln]
1501ln_backend = "fakewallet"
1502
1503[fake_wallet]
1504
1505[database]
1506engine = "sqlite"
1507"#
1508 )
1509 }
1510
1511 fn migration_paths(name: &str) -> (PathBuf, PathBuf, PathBuf) {
1512 let directory = crate::test_utils::unique_temp_path(name);
1513 fs::create_dir_all(&directory).expect("create migration test directory");
1514 let source = directory.join("legacy.toml");
1515 let output = directory.join("migrated.toml");
1516 (directory, source, output)
1517 }
1518
1519 #[test]
1520 fn released_v017_signatory_url_accepts_root_paths() {
1521 assert_eq!(
1522 parse_released_v017_signatory_url("http://127.0.0.1:10009/")
1523 .expect("parse HTTP URL with root path"),
1524 ("127.0.0.1".to_owned(), 10009)
1525 );
1526 assert_eq!(
1527 parse_released_v017_signatory_url("https://signatory.example/")
1528 .expect("parse HTTPS URL with root path"),
1529 ("signatory.example".to_owned(), 443)
1530 );
1531 }
1532
1533 #[test]
1534 fn migration_rejects_unknown_fields_outside_released_allowlist() {
1535 let (directory, source, output) = migration_paths("migrate_reject_unknown");
1536 fs::write(
1537 &source,
1538 r#"
1539[info]
1540listen_por = 8085
1541signatory_url = "http://127.0.0.1:10009"
1542"#,
1543 )
1544 .expect("write misspelled legacy config");
1545
1546 let error = migrate_legacy_configuration(&source, &output, None, None, false)
1547 .expect_err("migration must reject unknown fields");
1548 let message = format!("{error:#}");
1549 assert!(message.contains("info.listen_por"));
1550 assert!(!output.exists());
1551
1552 fs::remove_dir_all(directory).expect("remove migration test directory");
1553 }
1554
1555 #[cfg(feature = "fakewallet")]
1556 #[test]
1557 fn migration_materializes_operational_env_and_preserves_env_secrets() {
1558 let _env_lock = crate::test_utils::env_lock();
1559 let _environment = MintdEnvironment::cleared();
1560 let (directory, source, output) = migration_paths("migrate_env_secret");
1561 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write legacy config");
1562 std::env::set_var(crate::env_vars::ENV_LISTEN_PORT, "8123");
1563 std::env::set_var(crate::env_vars::ENV_MNEMONIC, TEST_MNEMONIC);
1564
1565 let outcome = migrate_legacy_configuration(&source, &output, None, None, false)
1566 .expect("migrate legacy config");
1567 let migrated = fs::read_to_string(&output).expect("read migrated config");
1568 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1569
1570 assert_eq!(settings.info.listen_port, 8123);
1571 assert_eq!(
1572 settings.info.mnemonic.as_deref(),
1573 Some("env:CDK_MINTD_MNEMONIC")
1574 );
1575 assert!(!migrated.contains(TEST_MNEMONIC));
1576 crate::config_service::ConfigurationService::validate_document(&migrated)
1577 .expect("validate migrated document");
1578 assert_eq!(outcome.secret_files_written, 0);
1579 assert!(outcome.secrets_dir.is_none());
1580
1581 fs::remove_dir_all(directory).expect("remove migration test directory");
1582 }
1583
1584 #[cfg(feature = "fakewallet")]
1585 #[test]
1586 fn migration_translates_released_v017_ln_section() {
1587 let _env_lock = crate::test_utils::env_lock();
1588 let _environment = MintdEnvironment::cleared();
1589 let (directory, source, output) = migration_paths("migrate_released_v017_ln");
1590 fs::write(
1591 &source,
1592 format!(
1593 r#"
1594[info]
1595mnemonic = "{TEST_MNEMONIC}"
1596
1597[ln]
1598ln_backend = "fakewallet"
1599unit = "sat"
1600invoice_description = "legacy description"
1601min_mint = 10
1602max_mint = 250000
1603min_melt = 20
1604max_melt = 125000
1605
1606[fake_wallet]
1607
1608[database]
1609engine = "sqlite"
1610"#
1611 ),
1612 )
1613 .expect("write released v0.17 config");
1614
1615 migrate_legacy_configuration(&source, &output, None, None, false)
1616 .expect("migrate released v0.17 [ln] section");
1617 let migrated = fs::read_to_string(&output).expect("read migrated config");
1618 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1619
1620 assert!(
1621 !migrated.contains("[ln]") && !migrated.contains("ln_backend"),
1622 "migrated document must use the canonical schema:\n{migrated}"
1623 );
1624 assert_eq!(settings.payment_backend.len(), 1);
1625 let backend = &settings.payment_backend[0];
1626 assert_eq!(
1627 backend.backend,
1628 crate::config::PaymentBackendType::FakeWallet
1629 );
1630 assert_eq!(backend.unit, cdk::nuts::CurrencyUnit::Sat);
1631 assert_eq!(
1632 backend.invoice_description.as_deref(),
1633 Some("legacy description")
1634 );
1635 assert_eq!(u64::from(backend.min_mint), 10);
1636 assert_eq!(u64::from(backend.max_mint), 250_000);
1637 assert_eq!(u64::from(backend.min_melt), 20);
1638 assert_eq!(u64::from(backend.max_melt), 125_000);
1639
1640 crate::config_service::ConfigurationService::validate_document(&migrated)
1641 .expect("validate migrated document");
1642
1643 fs::remove_dir_all(directory).expect("remove migration test directory");
1644 }
1645
1646 #[cfg(feature = "fakewallet")]
1647 #[test]
1648 fn migration_translates_released_v017_multi_ln_entries() {
1649 let _env_lock = crate::test_utils::env_lock();
1650 let _environment = MintdEnvironment::cleared();
1651 let (directory, source, output) = migration_paths("migrate_released_v017_multi_ln");
1652 fs::write(
1653 &source,
1654 format!(
1655 r#"
1656[info]
1657mnemonic = "{TEST_MNEMONIC}"
1658
1659[[ln]]
1660ln_backend = "fakewallet"
1661unit = "sat"
1662
1663[[ln]]
1664ln_backend = "fakewallet"
1665unit = "usd"
1666max_mint = 1000
1667
1668[fake_wallet]
1669supported_units = ["sat", "usd"]
1670
1671[database]
1672engine = "sqlite"
1673"#
1674 ),
1675 )
1676 .expect("write released v0.17 config");
1677
1678 migrate_legacy_configuration(&source, &output, None, None, false)
1679 .expect("migrate released v0.17 [[ln]] entries");
1680 let migrated = fs::read_to_string(&output).expect("read migrated config");
1681 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1682
1683 assert!(
1684 !migrated.contains("[[ln]]") && !migrated.contains("ln_backend"),
1685 "migrated document must use the canonical schema:\n{migrated}"
1686 );
1687 assert_eq!(settings.payment_backend.len(), 2);
1688 assert_eq!(
1689 settings.payment_backend[0].unit,
1690 cdk::nuts::CurrencyUnit::Sat
1691 );
1692 assert_eq!(
1693 settings.payment_backend[1].unit,
1694 cdk::nuts::CurrencyUnit::Usd
1695 );
1696 assert_eq!(u64::from(settings.payment_backend[1].max_mint), 1_000);
1697 assert_eq!(u64::from(settings.payment_backend[0].min_mint), 1);
1699 assert_eq!(u64::from(settings.payment_backend[0].max_mint), 500_000);
1700
1701 fs::remove_dir_all(directory).expect("remove migration test directory");
1702 }
1703
1704 #[cfg(feature = "fakewallet")]
1705 #[test]
1706 fn migration_applies_released_v017_ln_env_overrides() {
1707 let _env_lock = crate::test_utils::env_lock();
1708 let _environment = MintdEnvironment::cleared();
1709 let (directory, source, output) = migration_paths("migrate_released_v017_ln_env");
1710 fs::write(
1711 &source,
1712 format!(
1713 r#"
1714[info]
1715mnemonic = "{TEST_MNEMONIC}"
1716
1717[fake_wallet]
1718
1719[database]
1720engine = "sqlite"
1721"#
1722 ),
1723 )
1724 .expect("write released v0.17 config without [ln] section");
1725 std::env::set_var(RELEASED_V017_LN_BACKEND_ENV_VAR, "fakewallet");
1726 std::env::set_var(RELEASED_V017_LN_MIN_MINT_ENV_VAR, "10");
1727
1728 migrate_legacy_configuration(&source, &output, None, None, false)
1729 .expect("migrate released v0.17 env-only backend");
1730 let migrated = fs::read_to_string(&output).expect("read migrated config");
1731 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1732
1733 assert_eq!(settings.payment_backend.len(), 1);
1734 assert_eq!(
1735 settings.payment_backend[0].backend,
1736 crate::config::PaymentBackendType::FakeWallet
1737 );
1738 assert_eq!(u64::from(settings.payment_backend[0].min_mint), 10);
1739
1740 fs::remove_dir_all(directory).expect("remove migration test directory");
1741 }
1742
1743 #[cfg(feature = "fakewallet")]
1744 #[test]
1745 fn migration_canonical_env_wins_over_released_v017_ln_env() {
1746 let _env_lock = crate::test_utils::env_lock();
1747 let _environment = MintdEnvironment::cleared();
1748 let (directory, source, output) = migration_paths("migrate_canonical_env_wins");
1749 fs::write(
1750 &source,
1751 format!(
1752 r#"
1753[info]
1754mnemonic = "{TEST_MNEMONIC}"
1755
1756[ln]
1757ln_backend = "fakewallet"
1758
1759[fake_wallet]
1760
1761[database]
1762engine = "sqlite"
1763"#
1764 ),
1765 )
1766 .expect("write released v0.17 config");
1767 std::env::set_var(RELEASED_V017_LN_MIN_MINT_ENV_VAR, "10");
1768 std::env::set_var(crate::env_vars::ENV_PAYMENT_BACKEND_MIN_MINT, "77");
1769
1770 migrate_legacy_configuration(&source, &output, None, None, false)
1771 .expect("migrate with both legacy and canonical env vars");
1772 let migrated = fs::read_to_string(&output).expect("read migrated config");
1773 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1774
1775 assert_eq!(settings.payment_backend.len(), 1);
1776 assert_eq!(u64::from(settings.payment_backend[0].min_mint), 77);
1777
1778 fs::remove_dir_all(directory).expect("remove migration test directory");
1779 }
1780
1781 #[cfg(feature = "fakewallet")]
1782 #[test]
1783 fn migration_prefers_canonical_payment_backend_over_legacy_ln() {
1784 let _env_lock = crate::test_utils::env_lock();
1785 let _environment = MintdEnvironment::cleared();
1786 let (directory, source, output) = migration_paths("migrate_canonical_wins_over_ln");
1787 fs::write(
1788 &source,
1789 format!(
1790 r#"
1791[info]
1792mnemonic = "{TEST_MNEMONIC}"
1793
1794[ln]
1795ln_backend = "fakewallet"
1796unit = "usd"
1797min_mint = 99
1798
1799[payment_backend]
1800backend = "fakewallet"
1801unit = "sat"
1802min_mint = 1
1803
1804[fake_wallet]
1805
1806[database]
1807engine = "sqlite"
1808"#
1809 ),
1810 )
1811 .expect("write hybrid config");
1812
1813 migrate_legacy_configuration(&source, &output, None, None, false)
1814 .expect("migrate hybrid config");
1815 let migrated = fs::read_to_string(&output).expect("read migrated config");
1816 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1817
1818 assert_eq!(settings.payment_backend.len(), 1);
1819 assert_eq!(
1820 settings.payment_backend[0].unit,
1821 cdk::nuts::CurrencyUnit::Sat
1822 );
1823 assert_eq!(u64::from(settings.payment_backend[0].min_mint), 1);
1824
1825 fs::remove_dir_all(directory).expect("remove migration test directory");
1826 }
1827
1828 #[cfg(feature = "fakewallet")]
1829 #[test]
1830 fn migration_maps_released_v017_remote_signatory_fields() {
1831 let _env_lock = crate::test_utils::env_lock();
1832 let _environment = MintdEnvironment::cleared();
1833 let (directory, source, output) = migration_paths("migrate_released_v017_remote_signatory");
1834 let seed_file = directory.join("legacy-seed");
1835 fs::write(
1836 &source,
1837 released_v017_remote_signatory_document(
1838 "https://signatory.example:15061/",
1839 Some("/run/cdk/signatory-tls"),
1840 ),
1841 )
1842 .expect("write released v0.17 config");
1843 fs::write(&seed_file, TEST_MNEMONIC).expect("write released v0.17 seed file");
1844
1845 migrate_legacy_configuration(&source, &output, None, Some(&seed_file), false)
1846 .expect("migrate released v0.17 remote signatory with ignored seed file");
1847 let migrated = fs::read_to_string(&output).expect("read migrated config");
1848 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1849 let signatory = settings.signatory.expect("migrated signatory");
1850
1851 assert!(signatory.enabled);
1852 assert_eq!(signatory.address, "signatory.example");
1853 assert_eq!(signatory.port, 15061);
1854 assert_eq!(
1855 signatory.tls_dir,
1856 Some(PathBuf::from("/run/cdk/signatory-tls"))
1857 );
1858 assert!(!signatory.allow_insecure);
1859 assert!(settings.info.seed.is_none());
1860 assert!(settings.info.mnemonic.is_none());
1861
1862 fs::remove_dir_all(directory).expect("remove migration test directory");
1863 }
1864
1865 #[cfg(feature = "fakewallet")]
1866 #[test]
1867 fn migration_maps_released_v017_remote_signatory_environment() {
1868 let _env_lock = crate::test_utils::env_lock();
1869 let _environment = MintdEnvironment::cleared();
1870 let (directory, source, output) =
1871 migration_paths("migrate_released_v017_remote_signatory_env");
1872 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write released v0.17 config");
1873 std::env::set_var(
1874 RELEASED_V017_SIGNATORY_URL_ENV_VAR,
1875 "http://127.0.0.1:15062",
1876 );
1877
1878 migrate_legacy_configuration(&source, &output, None, None, false)
1879 .expect("migrate released v0.17 signatory environment");
1880 let migrated = fs::read_to_string(&output).expect("read migrated config");
1881 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1882 let signatory = settings.signatory.expect("migrated signatory");
1883
1884 assert!(signatory.enabled);
1885 assert_eq!(signatory.address, "127.0.0.1");
1886 assert_eq!(signatory.port, 15062);
1887 assert!(signatory.tls_dir.is_none());
1888 assert!(signatory.allow_insecure);
1889 assert!(settings.info.mnemonic.is_none());
1890
1891 fs::remove_dir_all(directory).expect("remove migration test directory");
1892 }
1893
1894 #[cfg(all(feature = "fakewallet", feature = "management-rpc"))]
1895 #[test]
1896 fn migration_maps_released_v017_management_rpc_tls() {
1897 let _env_lock = crate::test_utils::env_lock();
1898 let _environment = MintdEnvironment::cleared();
1899 let (directory, source, output) = migration_paths("migrate_released_v017_management_tls");
1900 let document = format!(
1901 r#"
1902{}
1903
1904[mint_management_rpc]
1905enabled = true
1906address = "127.0.0.1"
1907port = 18086
1908tls_dir_path = "/run/cdk/management-tls"
1909"#,
1910 legacy_document(TEST_MNEMONIC)
1911 );
1912 fs::write(&source, document).expect("write released v0.17 config");
1913
1914 migrate_legacy_configuration(&source, &output, None, None, false)
1915 .expect("migrate released v0.17 management RPC");
1916 let migrated = fs::read_to_string(&output).expect("read migrated config");
1917 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1918 let management = settings
1919 .mint_management_rpc
1920 .expect("migrated management RPC");
1921
1922 assert!(management.enabled);
1923 assert_eq!(
1924 management.tls_dir,
1925 Some(PathBuf::from("/run/cdk/management-tls"))
1926 );
1927 assert!(!management.allow_insecure);
1928
1929 fs::remove_dir_all(directory).expect("remove migration test directory");
1930 }
1931
1932 #[cfg(all(feature = "fakewallet", feature = "management-rpc"))]
1933 #[test]
1934 fn migration_preserves_released_v017_management_rpc_insecure_fallback() {
1935 let _env_lock = crate::test_utils::env_lock();
1936 let _environment = MintdEnvironment::cleared();
1937 let (directory, source, output) =
1938 migration_paths("migrate_released_v017_management_insecure");
1939 let document = format!(
1940 r#"
1941{}
1942
1943[mint_management_rpc]
1944enabled = true
1945"#,
1946 legacy_document(TEST_MNEMONIC)
1947 );
1948 fs::write(&source, document).expect("write released v0.17 config");
1949
1950 migrate_legacy_configuration(&source, &output, None, None, false)
1951 .expect("migrate released v0.17 insecure management RPC");
1952 let migrated = fs::read_to_string(&output).expect("read migrated config");
1953 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1954 let management = settings
1955 .mint_management_rpc
1956 .expect("migrated management RPC");
1957
1958 assert!(management.enabled);
1959 assert!(management.tls_dir.is_none());
1960 assert!(management.allow_insecure);
1961
1962 fs::remove_dir_all(directory).expect("remove migration test directory");
1963 }
1964
1965 #[cfg(all(feature = "fakewallet", feature = "management-rpc"))]
1966 #[test]
1967 fn migration_maps_released_v017_management_rpc_tls_environment() {
1968 let _env_lock = crate::test_utils::env_lock();
1969 let _environment = MintdEnvironment::cleared();
1970 let (directory, source, output) =
1971 migration_paths("migrate_released_v017_management_tls_env");
1972 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write released v0.17 config");
1973 std::env::set_var(crate::env_vars::ENV_MINT_MANAGEMENT_ENABLED, "true");
1974 std::env::set_var(
1975 RELEASED_V017_MANAGEMENT_TLS_DIR_ENV_VAR,
1976 "/run/cdk/management-tls-from-env",
1977 );
1978
1979 migrate_legacy_configuration(&source, &output, None, None, false)
1980 .expect("migrate released v0.17 management RPC environment");
1981 let migrated = fs::read_to_string(&output).expect("read migrated config");
1982 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
1983 let management = settings
1984 .mint_management_rpc
1985 .expect("migrated management RPC");
1986
1987 assert!(management.enabled);
1988 assert_eq!(
1989 management.tls_dir,
1990 Some(PathBuf::from("/run/cdk/management-tls-from-env"))
1991 );
1992 assert!(!management.allow_insecure);
1993
1994 fs::remove_dir_all(directory).expect("remove migration test directory");
1995 }
1996
1997 #[cfg(feature = "fakewallet")]
1998 #[test]
1999 fn migration_extracts_literal_secrets_into_owner_only_files() {
2000 let _env_lock = crate::test_utils::env_lock();
2001 let _environment = MintdEnvironment::cleared();
2002 let (directory, source, output) = migration_paths("migrate_literal_secret");
2003 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write legacy config");
2004
2005 let outcome = migrate_legacy_configuration(&source, &output, None, None, false)
2006 .expect("migrate legacy config");
2007 let migrated = fs::read_to_string(&output).expect("read migrated config");
2008 let secret_path = directory
2009 .join(DEFAULT_SECRETS_DIRECTORY)
2010 .join("mint-mnemonic");
2011
2012 assert!(!migrated.contains(TEST_MNEMONIC));
2013 assert!(migrated.contains(&format!("file:{}", secret_path.display())));
2014 assert_eq!(
2015 fs::read_to_string(&secret_path).expect("read extracted mnemonic"),
2016 TEST_MNEMONIC
2017 );
2018 assert_eq!(outcome.secret_files_written, 1);
2019 assert_eq!(outcome.secrets_dir.as_deref(), secret_path.parent());
2020 crate::config_service::ConfigurationService::validate_document(&migrated)
2021 .expect("validate migrated document");
2022
2023 #[cfg(unix)]
2024 {
2025 use std::os::unix::fs::PermissionsExt;
2026
2027 assert_eq!(
2028 fs::metadata(secret_path.parent().expect("secret parent"))
2029 .expect("secrets directory metadata")
2030 .permissions()
2031 .mode()
2032 & 0o777,
2033 0o700
2034 );
2035 assert_eq!(
2036 fs::metadata(&secret_path)
2037 .expect("secret metadata")
2038 .permissions()
2039 .mode()
2040 & 0o777,
2041 0o600
2042 );
2043 }
2044
2045 fs::remove_dir_all(directory).expect("remove migration test directory");
2046 }
2047
2048 #[test]
2049 fn secret_cleanup_preserves_destination_created_by_another_writer() {
2050 let directory = crate::test_utils::unique_temp_path("migrate_secret_write_race");
2051 fs::create_dir(&directory).expect("create secrets directory");
2052 let mut secrets = MigrationSecrets::new(directory.clone(), false);
2053 secrets.add("first", "first secret");
2054 secrets.add("second", "our second secret");
2055 let first = directory.join("first");
2056 let second = directory.join("second");
2057 fs::write(&second, "other writer").expect("create competing secret");
2058
2059 let error =
2060 write_secret_files(&secrets, false).expect_err("existing second secret must fail");
2061
2062 assert!(error.to_string().contains("could not create secret file"));
2063 assert!(!first.exists());
2064 assert_eq!(
2065 fs::read_to_string(&second).expect("read competing secret"),
2066 "other writer"
2067 );
2068
2069 fs::remove_dir_all(directory).expect("remove migration test directory");
2070 }
2071
2072 #[cfg(feature = "fakewallet")]
2073 #[test]
2074 fn forced_migration_replaces_existing_output_and_secrets() {
2075 let _env_lock = crate::test_utils::env_lock();
2076 let _environment = MintdEnvironment::cleared();
2077 let (directory, source, output) = migration_paths("migrate_force_replace");
2078 let secrets_dir = directory.join(DEFAULT_SECRETS_DIRECTORY);
2079 let secret_path = secrets_dir.join("mint-mnemonic");
2080 fs::create_dir(&secrets_dir).expect("create secrets directory");
2081 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write legacy config");
2082 fs::write(&output, "old output").expect("write old output");
2083 fs::write(&secret_path, "old mnemonic").expect("write old secret");
2084
2085 migrate_legacy_configuration(&source, &output, None, None, true)
2086 .expect("replace existing migration files");
2087
2088 let migrated = fs::read_to_string(&output).expect("read migrated output");
2089 assert!(migrated.contains(&format!("file:{}", secret_path.display())));
2090 assert_eq!(
2091 fs::read_to_string(&secret_path).expect("read replaced secret"),
2092 TEST_MNEMONIC
2093 );
2094 for path in fs::read_dir(&directory)
2095 .expect("read migration directory")
2096 .chain(fs::read_dir(&secrets_dir).expect("read secrets directory"))
2097 .map(|entry| entry.expect("directory entry").path())
2098 {
2099 assert!(
2100 !path
2101 .file_name()
2102 .expect("file name")
2103 .to_string_lossy()
2104 .starts_with(".cdk-mintd-migration-"),
2105 "temporary migration file remained at {}",
2106 path.display()
2107 );
2108 }
2109
2110 fs::remove_dir_all(directory).expect("remove migration test directory");
2111 }
2112
2113 #[test]
2114 fn forced_publish_rolls_back_after_partial_rename() {
2115 let directory = crate::test_utils::unique_temp_path("migrate_publish_rollback");
2116 fs::create_dir(&directory).expect("create migration test directory");
2117 let first = directory.join("first");
2118 let second = directory.join("second");
2119 fs::write(&first, "old first").expect("write first destination");
2120 fs::write(&second, "old second").expect("write second destination");
2121
2122 let mut staged = vec![
2123 stage_migration_file("test file", &first, "new first", false)
2124 .expect("stage first file"),
2125 stage_migration_file("test file", &second, "new second", false)
2126 .expect("stage second file"),
2127 ];
2128 fs::remove_file(&staged[1].staged).expect("remove second staged file");
2129
2130 let error = commit_staged_files(&mut staged).expect_err("publishing should fail");
2131 assert!(error.to_string().contains("could not publish test file"));
2132 assert_eq!(
2133 fs::read_to_string(&first).expect("read restored first destination"),
2134 "old first"
2135 );
2136 assert_eq!(
2137 fs::read_to_string(&second).expect("read restored second destination"),
2138 "old second"
2139 );
2140
2141 fs::remove_dir_all(directory).expect("remove migration test directory");
2142 }
2143
2144 #[cfg(all(feature = "fakewallet", unix))]
2145 #[test]
2146 fn migration_preserves_existing_secrets_directory_permissions() {
2147 use std::os::unix::fs::PermissionsExt;
2148
2149 let _env_lock = crate::test_utils::env_lock();
2150 let _environment = MintdEnvironment::cleared();
2151 let (directory, source, output) = migration_paths("migrate_existing_secret_permissions");
2152 let secrets_dir = directory.join("shared-secrets");
2153 fs::create_dir(&secrets_dir).expect("create existing secrets directory");
2154 fs::set_permissions(&secrets_dir, fs::Permissions::from_mode(0o750))
2155 .expect("set existing secrets directory permissions");
2156 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write legacy config");
2157
2158 migrate_legacy_configuration(&source, &output, Some(&secrets_dir), None, false)
2159 .expect("migrate into existing secrets directory");
2160
2161 assert_eq!(
2162 fs::metadata(&secrets_dir)
2163 .expect("existing secrets directory metadata")
2164 .permissions()
2165 .mode()
2166 & 0o777,
2167 0o750
2168 );
2169 assert_eq!(
2170 fs::metadata(secrets_dir.join("mint-mnemonic"))
2171 .expect("secret metadata")
2172 .permissions()
2173 .mode()
2174 & 0o777,
2175 0o600
2176 );
2177
2178 fs::remove_dir_all(directory).expect("remove migration test directory");
2179 }
2180
2181 #[cfg(feature = "fakewallet")]
2182 #[test]
2183 fn migration_preserves_legacy_seed_file_as_an_absolute_reference() {
2184 let _env_lock = crate::test_utils::env_lock();
2185 let _environment = MintdEnvironment::cleared();
2186 let (directory, source, output) = migration_paths("migrate_seed_file");
2187 let seed_file = directory.join("seed.txt");
2188 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write legacy config");
2189 fs::write(&seed_file, TEST_MNEMONIC).expect("write legacy seed file");
2190
2191 let outcome = migrate_legacy_configuration(&source, &output, None, Some(&seed_file), false)
2192 .expect("migrate legacy seed file");
2193 let migrated = fs::read_to_string(&output).expect("read migrated config");
2194 let settings = Settings::try_from_toml(&migrated).expect("parse migrated config");
2195 let canonical_seed_file = seed_file.canonicalize().expect("canonical seed path");
2196
2197 let expected_reference = format!("file:{}", canonical_seed_file.display());
2198 assert_eq!(
2199 settings.info.mnemonic.as_deref(),
2200 Some(expected_reference.as_str())
2201 );
2202 assert_eq!(outcome.secret_files_written, 0);
2203 assert!(outcome.secrets_dir.is_none());
2204 crate::config_service::ConfigurationService::validate_document(&migrated)
2205 .expect("validate migrated document");
2206
2207 fs::remove_dir_all(directory).expect("remove migration test directory");
2208 }
2209
2210 #[cfg(feature = "fakewallet")]
2211 #[test]
2212 fn migration_refuses_to_overwrite_source_or_output_by_default() {
2213 let _env_lock = crate::test_utils::env_lock();
2214 let _environment = MintdEnvironment::cleared();
2215 let (directory, source, output) = migration_paths("migrate_no_overwrite");
2216 let document = legacy_document(TEST_MNEMONIC);
2217 fs::write(&source, &document).expect("write legacy config");
2218 fs::write(&output, "existing").expect("write existing output");
2219
2220 let output_error = migrate_legacy_configuration(&source, &output, None, None, false)
2221 .expect_err("existing output should fail");
2222 assert!(output_error.to_string().contains("already exists"));
2223
2224 let source_error = migrate_legacy_configuration(&source, &source, None, None, true)
2225 .expect_err("source overwrite should fail");
2226 assert!(source_error.to_string().contains("must differ"));
2227
2228 let aliased_source = directory.join("missing").join("..").join("legacy.toml");
2229 let aliased_source_error =
2230 migrate_legacy_configuration(&source, &aliased_source, None, None, true)
2231 .expect_err("lexically aliased source overwrite should fail");
2232 assert!(aliased_source_error.to_string().contains("must differ"));
2233
2234 #[cfg(unix)]
2235 {
2236 let hard_link = directory.join("legacy-hard-link.toml");
2237 fs::hard_link(&source, &hard_link).expect("create source hard link");
2238 let hard_link_error =
2239 migrate_legacy_configuration(&source, &hard_link, None, None, true)
2240 .expect_err("source hard-link overwrite should fail");
2241 assert!(hard_link_error.to_string().contains("must differ"));
2242 }
2243
2244 assert_eq!(
2245 fs::read_to_string(&source).expect("read preserved source"),
2246 document
2247 );
2248
2249 fs::remove_dir_all(directory).expect("remove migration test directory");
2250 }
2251
2252 #[cfg(feature = "fakewallet")]
2253 #[test]
2254 fn migration_rejects_destinations_that_alias_input_secrets_or_each_other() {
2255 let _env_lock = crate::test_utils::env_lock();
2256 let _environment = MintdEnvironment::cleared();
2257 let (directory, source, _) = migration_paths("migrate_destination_aliases");
2258 let referenced_secret = directory.join("existing-mnemonic");
2259 let referenced_document =
2260 legacy_document(&SecretRef::File(PathBuf::from("existing-mnemonic")).to_string());
2261 fs::write(&source, referenced_document).expect("write referenced-secret config");
2262 fs::write(&referenced_secret, TEST_MNEMONIC).expect("write referenced secret");
2263
2264 let input_alias_error =
2265 migrate_legacy_configuration(&source, &referenced_secret, None, None, true)
2266 .expect_err("output aliasing an input secret should fail");
2267 assert!(input_alias_error.to_string().contains("must differ"));
2268 assert_eq!(
2269 fs::read_to_string(&referenced_secret).expect("read preserved referenced secret"),
2270 TEST_MNEMONIC
2271 );
2272
2273 fs::write(&source, legacy_document(TEST_MNEMONIC)).expect("write literal-secret config");
2274 let colliding_output = directory.join("mint-mnemonic");
2275 let destination_alias_error =
2276 migrate_legacy_configuration(&source, &colliding_output, Some(&directory), None, false)
2277 .expect_err("output aliasing a generated secret should fail");
2278 assert!(destination_alias_error.to_string().contains("must differ"));
2279 assert!(!colliding_output.exists());
2280
2281 fs::remove_dir_all(directory).expect("remove migration test directory");
2282 }
2283
2284 #[cfg(feature = "fakewallet")]
2285 #[test]
2286 fn migration_rejects_generated_secret_that_aliases_source() {
2287 let _env_lock = crate::test_utils::env_lock();
2288 let _environment = MintdEnvironment::cleared();
2289 let directory = crate::test_utils::unique_temp_path("migrate_secret_source_alias");
2290 fs::create_dir_all(&directory).expect("create migration test directory");
2291 let source = directory.join("mint-mnemonic");
2292 let output = directory.join("migrated.toml");
2293 let document = legacy_document(TEST_MNEMONIC);
2294 fs::write(&source, &document).expect("write legacy config");
2295
2296 let error = migrate_legacy_configuration(&source, &output, Some(&directory), None, true)
2297 .expect_err("generated secret aliasing the source should fail");
2298 assert!(error.to_string().contains("must differ"));
2299 assert_eq!(
2300 fs::read_to_string(&source).expect("read preserved source"),
2301 document
2302 );
2303 assert!(!output.exists());
2304
2305 fs::remove_dir_all(directory).expect("remove migration test directory");
2306 }
2307}