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
11pub const DEFAULT_LEASE_DURATION: &str = "15m";
13
14pub const LEASE_REUSE_BUFFER_SECS: i64 = 300;
16
17#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub config_hash: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct LeaseLedger {
39 #[serde(default)]
40 pub leases: Vec<LeaseRecord>,
41}
42
43pub struct LedgerLockGuard {
45 _lock: fslock::LockFile,
46}
47
48pub 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 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
71fn 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 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 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 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 pub fn save(&self, project_dir: &Path) -> Result<()> {
124 let path = Self::ledger_path(project_dir);
125 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 let cutoff = Utc::now() - chrono::Duration::hours(24);
136 let mut compacted = self.clone();
137 compacted.leases.retain(|r| {
138 if r.revoked {
139 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 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 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 pub fn add(&mut self, record: LeaseRecord) {
187 self.leases.push(record);
188 }
189
190 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 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 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 pub fn find(&self, lease_id: &str) -> Option<&LeaseRecord> {
223 self.leases.iter().find(|r| r.lease_id == lease_id)
224 }
225
226 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 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, }
260 }
261}
262
263#[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 unsafe { std::env::remove_var(key) };
276 }
277 }
278}
279
280pub 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 !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
326pub enum EncryptionProviderResult {
328 NotConfigured,
330 Available(String, Box<dyn providers::Provider>),
332 Unavailable(String, FnoxError),
334}
335
336pub 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#[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#[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
438pub 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
468pub 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
481pub 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
494pub 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
537pub struct CachedEntry {
540 pub credentials: IndexMap<String, String>,
541 pub encryption_provider: Option<String>,
542 pub lease_id: String,
543}
544
545pub 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
562pub 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
595pub 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#[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 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 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 let (cached_credentials, encryption_provider) =
716 cache_credentials(config, profile, &result.credentials, &result.lease_id).await;
717
718 {
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}