Skip to main content

cdk_mintd/
config_migration.rs

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/// Released v0.17 accepted either a single `[ln]` table or repeated `[[ln]]`
47/// entries, each with an `ln_backend` key instead of the current `backend`.
48#[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/// Legacy `CDK_MINTD_LN_*` environment overrides, as released in v0.17.
70#[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/// Result of converting a legacy mintd configuration.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct MigrationOutcome {
136    /// Absolute path of the generated import document.
137    pub output: PathBuf,
138    /// Directory containing generated secret files, when any were needed.
139    pub secrets_dir: Option<PathBuf>,
140    /// Number of literal secrets copied into generated files.
141    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
199/// Converts a legacy TOML document and its active `CDK_MINTD_*` overrides into
200/// one database-importable TOML document.
201///
202/// Environment-backed secrets remain `env:` references. Literal secrets are
203/// copied into owner-only files and replaced by absolute `file:` references.
204/// The source document is never overwritten.
205pub 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    // Translate the released v0.17 `[ln]` section before applying environment
227    // overrides so the backend configuration exists when validation runs,
228    // exactly as it did in v0.17.
229    apply_released_v017_payment_backend_file(&mut effective, &released_v017)?;
230    // Re-apply the released v0.17 `CDK_MINTD_LN_*` overrides before the
231    // canonical environment so validation sees the same backends v0.17 would
232    // have configured; canonical `CDK_MINTD_PAYMENT_BACKEND_*` variables are
233    // applied afterwards and therefore win when both are set.
234    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    // Parsed with serde/toml directly (rather than the `config` crate) so the
456    // untagged one-or-many `[ln]` section deserializes correctly.
457    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
470/// Translates a released v0.17 `[ln]`/`[[ln]]` document section into the
471/// canonical `[payment_backend]` model.
472///
473/// Canonical `payment_backend` entries win over the legacy section when both
474/// are present.
475fn 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
519/// Parses a released v0.17 backend name. The v0.17 TOML section accepted
520/// `none` through serde while its `FromStr` (used for the env var) did not;
521/// accept it in both paths during migration so an explicit disable migrates.
522fn 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
534/// Applies the released v0.17 `CDK_MINTD_LN_*` environment variables with the
535/// semantics they had in v0.17: they only ever target a single configured
536/// entry (creating it when the document has none). Canonical
537/// `CDK_MINTD_PAYMENT_BACKEND_*` variables are applied afterwards, so they
538/// take precedence when both are set.
539fn 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    // Released v0.17 selected the remote signatory before either local source.
624    // Remove ignored local material so the new mutually-exclusive model keeps
625    // the same effective signer.
626    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    // Tonic accepted a root path in the released v0.17 setting, so preserve
641    // compatibility with URLs such as `https://signatory.example/`.
642    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        // Released v0.17 fell back to plaintext when the TLS directory did not
716        // exist. Preserve that behavior explicitly in the migrated document.
717        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        // The successful create-new open above establishes that this
1354        // invocation created the path. Never remove it when opening failed,
1355        // because another writer may have won the race after preflight.
1356        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        // Unspecified legacy limits take the v0.17 defaults
1698        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}