Skip to main content

fnox_core/
lease.rs

1use crate::config::Config;
2use crate::env;
3use crate::error::{FnoxError, Result};
4use crate::providers::{self, ProviderCapability};
5use chrono::{DateTime, Utc};
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// Default lease duration when none is specified
12pub const DEFAULT_LEASE_DURATION: &str = "15m";
13
14/// Buffer in seconds before expiry when a cached lease is no longer considered reusable
15pub const LEASE_REUSE_BUFFER_SECS: i64 = 300;
16
17/// A record of an issued lease, stored in the lease ledger
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct LeaseRecord {
20    pub lease_id: String,
21    pub backend_name: String,
22    pub label: String,
23    pub created_at: DateTime<Utc>,
24    pub expires_at: Option<DateTime<Utc>>,
25    pub revoked: bool,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub cached_credentials: Option<IndexMap<String, String>>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub encryption_provider: Option<String>,
30    /// Hash of the backend config at lease creation time, used to invalidate
31    /// cached credentials when the config changes (e.g., role ARN rotation).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub config_hash: Option<String>,
34}
35
36/// The lease ledger, tracking all issued leases
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct LeaseLedger {
39    #[serde(default)]
40    pub leases: Vec<LeaseRecord>,
41}
42
43/// RAII guard for the ledger file lock. The lock is released when dropped.
44pub struct LedgerLockGuard {
45    _lock: fslock::LockFile,
46}
47
48/// Determine the project directory for scoping the lease ledger.
49///
50/// Uses `Config::project_dir` (the nearest directory to cwd containing a config
51/// file, set during recursive loading) when available. Falls back to resolving
52/// `config_path` against cwd for non-recursive loads (explicit `--config` flag).
53pub fn project_dir_from_config(config: &crate::config::Config, config_path: &Path) -> PathBuf {
54    if let Some(ref dir) = config.project_dir {
55        return dir.clone();
56    }
57    // Fallback for explicit --config paths
58    let resolved = if config_path.is_relative() {
59        std::env::current_dir()
60            .map(|cwd| cwd.join(config_path))
61            .unwrap_or_else(|_| config_path.to_path_buf())
62    } else {
63        config_path.to_path_buf()
64    };
65    resolved
66        .parent()
67        .map(|p| p.to_path_buf())
68        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
69}
70
71/// Hash a project directory path to produce a unique ledger filename.
72/// Uses blake3 for stability across Rust toolchain upgrades (DefaultHasher
73/// is explicitly not guaranteed to be stable across releases).
74fn hash_project_dir(project_dir: &Path) -> String {
75    let hash = blake3::hash(project_dir.to_string_lossy().as_bytes());
76    hash.to_hex()[..16].to_string()
77}
78
79impl LeaseLedger {
80    /// Path to the lease ledger file, scoped to a project directory.
81    /// Uses `~/.local/state/fnox/leases/` (XDG state dir).
82    fn ledger_path(project_dir: &Path) -> PathBuf {
83        let hash = hash_project_dir(project_dir);
84        env::FNOX_STATE_DIR
85            .join("leases")
86            .join(format!("{hash}.toml"))
87    }
88
89    /// Acquire an exclusive file lock for the ledger.
90    /// Returns a guard that releases the lock on drop.
91    ///
92    /// Locks a separate `.lock` sentinel file rather than the data file itself,
93    /// because `save()` uses atomic rename which replaces the data file's inode.
94    /// Locking the data file directly would break mutual exclusion: after rename,
95    /// new processes would lock the new inode while the old process holds the old one.
96    pub fn lock(project_dir: &Path) -> Result<LedgerLockGuard> {
97        let ledger_path = Self::ledger_path(project_dir);
98        let lock_path = ledger_path.with_extension("lock");
99        let lock = xx::fslock::FSLock::new(&lock_path)
100            .lock()
101            .map_err(|e| FnoxError::Config(format!("Failed to acquire ledger lock: {e}")))?;
102        Ok(LedgerLockGuard { _lock: lock })
103    }
104
105    /// Load the lease ledger from disk, creating an empty one if it doesn't exist.
106    /// The ledger is scoped to the project directory (parent of the config file).
107    /// Caller should hold a `LedgerLockGuard` when performing load → mutate → save.
108    pub fn load(project_dir: &Path) -> Result<Self> {
109        let path = Self::ledger_path(project_dir);
110        if !path.exists() {
111            return Ok(Self::default());
112        }
113        let content = fs::read_to_string(&path).map_err(|e| FnoxError::ConfigReadFailed {
114            path: path.clone(),
115            source: e,
116        })?;
117        let ledger: Self = toml_edit::de::from_str(&content)
118            .map_err(|e| FnoxError::ConfigParseError { source: e })?;
119        Ok(ledger)
120    }
121
122    /// Save the lease ledger to disk, pruning stale entries first
123    pub fn save(&self, project_dir: &Path) -> Result<()> {
124        let path = Self::ledger_path(project_dir);
125        // Ensure leases directory exists
126        if let Some(parent) = path.parent() {
127            fs::create_dir_all(parent).map_err(|e| FnoxError::CreateDirFailed {
128                path: parent.to_path_buf(),
129                source: e,
130            })?;
131        }
132        // Compact: drop entries that are revoked or expired more than 24h ago.
133        // For records with no expiry (e.g., command backend with no expires_at),
134        // use created_at + 24h as a staleness bound to prevent unbounded growth.
135        let cutoff = Utc::now() - chrono::Duration::hours(24);
136        let mut compacted = self.clone();
137        compacted.leases.retain(|r| {
138            if r.revoked {
139                // Keep revoked records for audit visibility only if they have
140                // an expiry within the window. Revoked records with no expiry
141                // use created_at + 24h as the cutoff.
142                return match r.expires_at {
143                    Some(exp) => exp > cutoff,
144                    None => r.created_at > cutoff,
145                };
146            }
147            match r.expires_at {
148                Some(exp) => exp > cutoff,
149                // No expiry: prune if created more than 24h ago
150                None => r.created_at > cutoff,
151            }
152        });
153        let content = toml_edit::ser::to_string_pretty(&compacted)
154            .map_err(|e| FnoxError::ConfigSerializeError { source: e })?;
155        // Atomic write: write to a temp file then rename, so readers never see
156        // a partially-written or truncated ledger (crash safety).
157        let tmp_path = path.with_extension("toml.tmp");
158        #[cfg(unix)]
159        {
160            use std::os::unix::fs::OpenOptionsExt;
161            std::fs::OpenOptions::new()
162                .create(true)
163                .write(true)
164                .truncate(true)
165                .mode(0o600)
166                .open(&tmp_path)
167                .and_then(|mut f| std::io::Write::write_all(&mut f, content.as_bytes()))
168                .map_err(|e| FnoxError::ConfigWriteFailed {
169                    path: tmp_path.clone(),
170                    source: e,
171                })?;
172        }
173        #[cfg(not(unix))]
174        fs::write(&tmp_path, &content).map_err(|e| FnoxError::ConfigWriteFailed {
175            path: tmp_path.clone(),
176            source: e,
177        })?;
178        fs::rename(&tmp_path, &path).map_err(|e| FnoxError::ConfigWriteFailed {
179            path: path.clone(),
180            source: e,
181        })?;
182        Ok(())
183    }
184
185    /// Add a new lease record to the ledger
186    pub fn add(&mut self, record: LeaseRecord) {
187        self.leases.push(record);
188    }
189
190    /// Mark a lease as revoked by ID, clearing any cached credentials
191    pub fn mark_revoked(&mut self, lease_id: &str) -> bool {
192        for record in &mut self.leases {
193            if record.lease_id == lease_id {
194                record.revoked = true;
195                record.cached_credentials = None;
196                record.encryption_provider = None;
197                return true;
198            }
199        }
200        false
201    }
202
203    /// Get all active (non-revoked, non-expired) leases
204    pub fn active_leases(&self) -> Vec<&LeaseRecord> {
205        let now = Utc::now();
206        self.leases
207            .iter()
208            .filter(|r| !r.revoked && r.expires_at.is_none_or(|exp| exp > now))
209            .collect()
210    }
211
212    /// Get all expired (non-revoked) leases
213    pub fn expired_leases(&self) -> Vec<&LeaseRecord> {
214        let now = Utc::now();
215        self.leases
216            .iter()
217            .filter(|r| !r.revoked && r.expires_at.is_some_and(|exp| exp <= now))
218            .collect()
219    }
220
221    /// Find a lease by ID
222    pub fn find(&self, lease_id: &str) -> Option<&LeaseRecord> {
223        self.leases.iter().find(|r| r.lease_id == lease_id)
224    }
225
226    /// Find a reusable cached lease for the given backend name and config hash.
227    /// Returns the lease with the latest expiry that is still valid (with buffer).
228    /// Never-expiring leases (expires_at: None) are ranked highest.
229    /// Leases with a mismatched config_hash are skipped to prevent returning
230    /// stale credentials after backend config changes (e.g., role ARN rotation).
231    pub fn find_reusable(&self, backend_name: &str, config_hash: &str) -> Option<&LeaseRecord> {
232        self.leases
233            .iter()
234            .filter(|r| {
235                r.backend_name == backend_name
236                    && r.is_reusable()
237                    && r.config_hash.as_deref().is_none_or(|h| h == config_hash)
238            })
239            .max_by_key(|r| match r.expires_at {
240                None => DateTime::<Utc>::MAX_UTC,
241                Some(exp) => exp,
242            })
243    }
244}
245
246impl LeaseRecord {
247    /// Check if this lease can be reused: not revoked, has cached credentials,
248    /// and expires_at minus buffer is still in the future.
249    pub fn is_reusable(&self) -> bool {
250        if self.revoked || self.cached_credentials.is_none() {
251            return false;
252        }
253        match self.expires_at {
254            Some(exp) => {
255                let buffer = chrono::Duration::seconds(LEASE_REUSE_BUFFER_SECS);
256                exp - buffer > Utc::now()
257            }
258            None => true, // No expiry means it's always valid
259        }
260    }
261}
262
263/// RAII guard that removes temporary process env vars on drop.
264/// Ensures cleanup on all exit paths, including early returns from `?`.
265#[derive(Default)]
266pub struct TempEnvGuard {
267    pub keys: Vec<String>,
268}
269
270impl Drop for TempEnvGuard {
271    fn drop(&mut self) {
272        for key in &self.keys {
273            // TODO: unsafe remove_var on a multi-threaded Tokio runtime is
274            // technically UB. Refactor to pass credentials explicitly.
275            unsafe { std::env::remove_var(key) };
276        }
277    }
278}
279
280/// Parse a human-readable duration string (e.g., "15m", "1h", "2h30m")
281pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
282    let s = s.trim();
283    let mut total_secs: u64 = 0;
284    let mut current_num = String::new();
285
286    for c in s.chars() {
287        if c.is_ascii_digit() {
288            current_num.push(c);
289        } else {
290            let num: u64 = current_num
291                .parse()
292                .map_err(|_| FnoxError::Config(format!("Invalid duration: '{s}'")))?;
293            current_num.clear();
294
295            match c {
296                's' => total_secs += num,
297                'm' => total_secs += num * 60,
298                'h' => total_secs += num * 3600,
299                'd' => total_secs += num * 86400,
300                _ => {
301                    return Err(FnoxError::Config(format!(
302                        "Invalid duration unit '{c}' in '{s}'. Use s, m, h, or d"
303                    )));
304                }
305            }
306        }
307    }
308
309    // If there's a trailing number with no unit, treat as seconds
310    if !current_num.is_empty() {
311        let num: u64 = current_num
312            .parse()
313            .map_err(|_| FnoxError::Config(format!("Invalid duration: '{s}'")))?;
314        total_secs += num;
315    }
316
317    if total_secs == 0 {
318        return Err(FnoxError::Config(
319            "Duration must be greater than 0".to_string(),
320        ));
321    }
322
323    Ok(std::time::Duration::from_secs(total_secs))
324}
325
326/// Result of searching for an encryption provider
327pub enum EncryptionProviderResult {
328    /// No encryption-capable default_provider is configured
329    NotConfigured,
330    /// An encryption provider was found and instantiated
331    Available(String, Box<dyn providers::Provider>),
332    /// An encryption provider is configured but failed to instantiate
333    Unavailable(String, FnoxError),
334}
335
336/// Find an encryption provider if one is configured (default_provider with Encryption capability)
337pub async fn find_encryption_provider(config: &Config, profile: &str) -> EncryptionProviderResult {
338    let provider_name = match config.get_default_provider(profile) {
339        Ok(Some(name)) => name,
340        _ => return EncryptionProviderResult::NotConfigured,
341    };
342
343    let providers_map = config.get_providers(profile);
344    let provider_config = match providers_map.get(&provider_name) {
345        Some(c) => c,
346        None => return EncryptionProviderResult::NotConfigured,
347    };
348
349    let provider =
350        match providers::get_provider_resolved(config, profile, &provider_name, provider_config)
351            .await
352        {
353            Ok(p) => p,
354            Err(e) => {
355                return EncryptionProviderResult::Unavailable(provider_name, e);
356            }
357        };
358
359    if provider
360        .capabilities()
361        .contains(&ProviderCapability::Encryption)
362    {
363        EncryptionProviderResult::Available(provider_name, provider)
364    } else {
365        EncryptionProviderResult::NotConfigured
366    }
367}
368
369/// Record a lease result in the ledger (add + save). No lock management —
370/// the caller must hold the ledger lock. Shared by `create_and_record_lease`
371/// and `resolve_lease` to avoid duplicating the add+save+warn pattern.
372#[allow(clippy::too_many_arguments)]
373fn record_lease(
374    ledger: &mut LeaseLedger,
375    result: &crate::lease_backends::Lease,
376    backend_name: &str,
377    label: &str,
378    config_hash: String,
379    cached_credentials: Option<IndexMap<String, String>>,
380    encryption_provider: Option<String>,
381    project_dir: &Path,
382) {
383    ledger.add(LeaseRecord {
384        lease_id: result.lease_id.clone(),
385        backend_name: backend_name.to_string(),
386        label: label.to_string(),
387        created_at: Utc::now(),
388        expires_at: result.expires_at,
389        revoked: false,
390        cached_credentials,
391        encryption_provider,
392        config_hash: Some(config_hash),
393    });
394    if let Err(save_err) = ledger.save(project_dir) {
395        tracing::warn!(
396            "Lease '{}' created for backend '{}' but ledger save failed: {}. \
397             This lease is untracked and must be revoked manually.",
398            result.lease_id,
399            backend_name,
400            save_err
401        );
402    }
403}
404
405/// Create a lease, cache credentials, and record it in the ledger.
406/// Used by `fnox lease create` where the caller manages its own lock.
407#[allow(clippy::too_many_arguments)]
408pub async fn create_and_record_lease(
409    backend: &dyn crate::lease_backends::LeaseBackend,
410    backend_name: &str,
411    label: &str,
412    duration: std::time::Duration,
413    config_hash: String,
414    config: &Config,
415    profile: &str,
416    ledger: &mut LeaseLedger,
417    project_dir: &Path,
418) -> Result<crate::lease_backends::Lease> {
419    let result = backend.create_lease(duration, label).await?;
420
421    let (cached_credentials, encryption_provider) =
422        cache_credentials(config, profile, &result.credentials, &result.lease_id).await;
423
424    record_lease(
425        ledger,
426        &result,
427        backend_name,
428        label,
429        config_hash,
430        cached_credentials,
431        encryption_provider,
432        project_dir,
433    );
434
435    Ok(result)
436}
437
438/// Set resolved secrets as process env vars so lease backend SDKs can find
439/// master credentials during lease creation. Returns temp files that must be
440/// kept alive for the duration of the operation (for `as_file` secrets).
441///
442/// # Safety
443/// Uses `unsafe { std::env::set_var }` which is technically UB on a
444/// multi-threaded Tokio runtime. TODO: refactor to pass credentials explicitly.
445pub fn set_secrets_as_env(
446    resolved_secrets: &IndexMap<String, Option<String>>,
447    profile_secrets: &IndexMap<String, crate::config::SecretConfig>,
448    guard: &mut TempEnvGuard,
449) -> Result<Vec<tempfile::NamedTempFile>> {
450    let mut temp_files = Vec::new();
451    for (key, value) in resolved_secrets {
452        if let Some(value) = value {
453            let env_value = if profile_secrets.get(key).is_some_and(|sc| sc.as_file) {
454                let temp_file = crate::temp_file_secrets::create_ephemeral_secret_file(key, value)?;
455                let path = temp_file.path().to_string_lossy().to_string();
456                temp_files.push(temp_file);
457                path
458            } else {
459                value.clone()
460            };
461            unsafe { std::env::set_var(key, &env_value) };
462            guard.keys.push(key.clone());
463        }
464    }
465    Ok(temp_files)
466}
467
468/// Encrypt credential values using an encryption provider
469pub async fn encrypt_credentials(
470    provider: &dyn providers::Provider,
471    credentials: &IndexMap<String, String>,
472) -> Result<IndexMap<String, String>> {
473    let mut encrypted = IndexMap::new();
474    for (key, value) in credentials {
475        let enc = provider.encrypt(value).await?;
476        encrypted.insert(key.clone(), enc);
477    }
478    Ok(encrypted)
479}
480
481/// Decrypt cached credential values using an encryption provider
482pub async fn decrypt_credentials(
483    provider: &dyn providers::Provider,
484    cached: &IndexMap<String, String>,
485) -> Result<IndexMap<String, String>> {
486    let mut decrypted = IndexMap::new();
487    for (key, value) in cached {
488        let dec = provider.get_secret(value).await?;
489        decrypted.insert(key.clone(), dec);
490    }
491    Ok(decrypted)
492}
493
494/// Determine how to cache credentials: encrypt if a provider is available,
495/// skip caching if the provider is configured but unavailable, or store
496/// plaintext if no encryption provider is configured.
497pub async fn cache_credentials(
498    config: &Config,
499    profile: &str,
500    credentials: &IndexMap<String, String>,
501    lease_id: &str,
502) -> (Option<IndexMap<String, String>>, Option<String>) {
503    match find_encryption_provider(config, profile).await {
504        EncryptionProviderResult::Available(enc_name, provider) => {
505            match encrypt_credentials(provider.as_ref(), credentials).await {
506                Ok(encrypted) => {
507                    tracing::debug!("Caching encrypted credentials for lease '{}'", lease_id);
508                    (Some(encrypted), Some(enc_name))
509                }
510                Err(e) => {
511                    tracing::warn!(
512                        "Failed to encrypt credentials for caching: {}, skipping cache",
513                        e
514                    );
515                    (None, None)
516                }
517            }
518        }
519        EncryptionProviderResult::Unavailable(enc_name, e) => {
520            tracing::warn!(
521                "Encryption provider '{}' configured but unavailable: {}, skipping credential cache",
522                enc_name,
523                e
524            );
525            (None, None)
526        }
527        EncryptionProviderResult::NotConfigured => {
528            tracing::debug!(
529                "No encryption provider, caching plaintext credentials for lease '{}'",
530                lease_id
531            );
532            (Some(credentials.clone()), None)
533        }
534    }
535}
536
537/// Data extracted from a cached lease entry. All fields are cloned so the
538/// ledger (and its file lock) can be released before any async work.
539pub struct CachedEntry {
540    pub credentials: IndexMap<String, String>,
541    pub encryption_provider: Option<String>,
542    pub lease_id: String,
543}
544
545/// Synchronous ledger lookup: find a reusable cached entry and clone the
546/// relevant fields. This is safe to call under a file lock since it performs
547/// no I/O beyond the already-loaded ledger.
548pub fn find_cached_entry(
549    ledger: &LeaseLedger,
550    name: &str,
551    config_hash: &str,
552) -> Option<CachedEntry> {
553    let cached_lease = ledger.find_reusable(name, config_hash)?;
554    let cached_creds = cached_lease.cached_credentials.as_ref()?;
555    Some(CachedEntry {
556        credentials: cached_creds.clone(),
557        encryption_provider: cached_lease.encryption_provider.clone(),
558        lease_id: cached_lease.lease_id.clone(),
559    })
560}
561
562/// Resolve a [`CachedEntry`] into usable credentials, decrypting if needed.
563/// Returns `None` if decryption fails (caller should create a fresh lease).
564///
565/// When called from `get.rs` the plaintext branch is unreachable because
566/// `resolve_from_lease` returns early for plaintext entries before calling
567/// this function. The plaintext path is exercised via `resolve_lease`'s
568/// TOCTOU cache check (shared by both `fnox exec` and `fnox get`).
569pub async fn resolve_cached_entry(
570    entry: CachedEntry,
571    config: &Config,
572    profile: &str,
573    backend_name: &str,
574) -> Option<IndexMap<String, String>> {
575    if let Some(ref enc_provider_name) = entry.encryption_provider {
576        try_decrypt_cached(
577            config,
578            profile,
579            enc_provider_name,
580            &entry.credentials,
581            &entry.lease_id,
582            backend_name,
583        )
584        .await
585    } else {
586        tracing::debug!(
587            "Reusing cached plaintext lease '{}' for backend '{}'",
588            entry.lease_id,
589            backend_name
590        );
591        Some(entry.credentials)
592    }
593}
594
595/// Attempt to decrypt cached credentials using the named encryption provider.
596/// Returns `None` if the provider is unavailable or decryption fails.
597pub async fn try_decrypt_cached(
598    config: &Config,
599    profile: &str,
600    enc_provider_name: &str,
601    cached_creds: &IndexMap<String, String>,
602    lease_id: &str,
603    backend_name: &str,
604) -> Option<IndexMap<String, String>> {
605    match find_encryption_provider(config, profile).await {
606        EncryptionProviderResult::Available(found_name, provider)
607            if found_name == enc_provider_name =>
608        {
609            match decrypt_credentials(provider.as_ref(), cached_creds).await {
610                Ok(decrypted) => {
611                    tracing::debug!(
612                        "Reusing cached encrypted lease '{}' for backend '{}'",
613                        lease_id,
614                        backend_name
615                    );
616                    Some(decrypted)
617                }
618                Err(e) => {
619                    tracing::warn!(
620                        "Failed to decrypt cached lease '{}': {}, creating fresh lease",
621                        lease_id,
622                        e
623                    );
624                    None
625                }
626            }
627        }
628        _ => {
629            tracing::warn!(
630                "Encryption provider '{}' not available for cached lease '{}', creating fresh lease",
631                enc_provider_name,
632                lease_id
633            );
634            None
635        }
636    }
637}
638
639/// Resolve a lease backend into credentials, reusing cached credentials when available.
640/// Shared between `fnox exec` and `fnox get`.
641///
642/// Manages its own ledger locks with minimal scope: a short lock for the
643/// cache check (sync read, release, async decrypt), the lease creation
644/// network call and credential encryption run with no lock held, and a final
645/// short lock for the ledger write. This prevents concurrent `fnox get`/`exec`
646/// calls from serializing on a single lock for the duration of network I/O.
647///
648/// When `skip_cache` is true the initial cache check is skipped entirely. Use
649/// this when the caller has already performed a cache lookup and decryption
650/// attempt (e.g. `get.rs` does its own encrypted-cache check after injecting
651/// encryption-provider credentials), avoiding a redundant network round-trip
652/// to the encryption provider on cache-miss.
653#[allow(clippy::too_many_arguments)]
654pub async fn resolve_lease(
655    name: &str,
656    lease_config: &crate::lease_backends::LeaseBackendConfig,
657    config: &Config,
658    profile: &str,
659    project_dir: &Path,
660    prereq_missing: Option<&str>,
661    label_prefix: &str,
662    skip_cache: bool,
663) -> Result<IndexMap<String, String>> {
664    let config_hash = lease_config.config_hash();
665
666    if !skip_cache {
667        // Cache check: sync ledger read under a short lock, then release
668        // before async decryption. Guards against a concurrent process writing
669        // a valid cache entry between an earlier check and now.
670        let cached_entry = {
671            let _lock = LeaseLedger::lock(project_dir)?;
672            let ledger = LeaseLedger::load(project_dir)?;
673            find_cached_entry(&ledger, name, &config_hash)
674        };
675        if let Some(entry) = cached_entry
676            && let Some(creds) = resolve_cached_entry(entry, config, profile, name).await
677        {
678            return Ok(creds);
679        }
680    }
681
682    if let Some(missing) = prereq_missing {
683        return Err(FnoxError::Config(format!(
684            "Lease '{}': no usable cached credentials and \
685             prerequisites are missing: {}\n\
686             Run 'fnox lease create -i {}' to set up credentials interactively.",
687            name, missing, name
688        )));
689    }
690    let backend = lease_config.create_backend()?;
691
692    let duration_str = lease_config.duration().unwrap_or(DEFAULT_LEASE_DURATION);
693    let duration = parse_duration(duration_str)?;
694
695    let max_duration = backend.max_lease_duration();
696    if duration > max_duration {
697        return Err(FnoxError::Config(format!(
698            "Lease duration '{}' for '{}' exceeds maximum {:?}",
699            duration_str, name, max_duration
700        )));
701    }
702
703    // Create the lease (async network call) with no lock held.
704    let label = format!("fnox-{}-{}", label_prefix, name);
705    let result = backend.create_lease(duration, &label).await?;
706
707    tracing::debug!(
708        "Created lease '{}' for backend '{}' (expires {:?})",
709        result.lease_id,
710        name,
711        result.expires_at
712    );
713
714    // Encrypt credentials for caching (async, may call encryption provider).
715    let (cached_credentials, encryption_provider) =
716        cache_credentials(config, profile, &result.credentials, &result.lease_id).await;
717
718    // Acquire lock only for the synchronous ledger write.
719    {
720        let _lock = LeaseLedger::lock(project_dir)?;
721        let mut ledger = LeaseLedger::load(project_dir)?;
722        record_lease(
723            &mut ledger,
724            &result,
725            name,
726            &label,
727            config_hash,
728            cached_credentials,
729            encryption_provider,
730            project_dir,
731        );
732    }
733
734    Ok(result.credentials)
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    #[test]
742    fn test_parse_duration_minutes() {
743        assert_eq!(parse_duration("15m").unwrap().as_secs(), 900);
744    }
745
746    #[test]
747    fn test_parse_duration_hours() {
748        assert_eq!(parse_duration("1h").unwrap().as_secs(), 3600);
749    }
750
751    #[test]
752    fn test_parse_duration_combined() {
753        assert_eq!(parse_duration("2h30m").unwrap().as_secs(), 9000);
754    }
755
756    #[test]
757    fn test_parse_duration_seconds() {
758        assert_eq!(parse_duration("30s").unwrap().as_secs(), 30);
759    }
760
761    #[test]
762    fn test_parse_duration_bare_number() {
763        assert_eq!(parse_duration("300").unwrap().as_secs(), 300);
764    }
765
766    #[test]
767    fn test_parse_duration_invalid() {
768        assert!(parse_duration("").is_err());
769        assert!(parse_duration("0m").is_err());
770        assert!(parse_duration("abc").is_err());
771    }
772}