use crate::config::Config;
use crate::env;
use crate::error::{FnoxError, Result};
use crate::providers::{self, ProviderCapability};
use chrono::{DateTime, Utc};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT_LEASE_DURATION: &str = "15m";
pub const LEASE_REUSE_BUFFER_SECS: i64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaseRecord {
pub lease_id: String,
pub backend_name: String,
pub label: String,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_credentials: Option<IndexMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LeaseLedger {
#[serde(default)]
pub leases: Vec<LeaseRecord>,
}
pub struct LedgerLockGuard {
_lock: fslock::LockFile,
}
pub fn project_dir_from_config(config: &crate::config::Config, config_path: &Path) -> PathBuf {
if let Some(ref dir) = config.project_dir {
return dir.clone();
}
let resolved = if config_path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(config_path))
.unwrap_or_else(|_| config_path.to_path_buf())
} else {
config_path.to_path_buf()
};
resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn hash_project_dir(project_dir: &Path) -> String {
let hash = blake3::hash(project_dir.to_string_lossy().as_bytes());
hash.to_hex()[..16].to_string()
}
impl LeaseLedger {
fn ledger_path(project_dir: &Path) -> PathBuf {
let hash = hash_project_dir(project_dir);
env::FNOX_STATE_DIR
.join("leases")
.join(format!("{hash}.toml"))
}
pub fn lock(project_dir: &Path) -> Result<LedgerLockGuard> {
let ledger_path = Self::ledger_path(project_dir);
let lock_path = ledger_path.with_extension("lock");
let lock = xx::fslock::FSLock::new(&lock_path)
.lock()
.map_err(|e| FnoxError::Config(format!("Failed to acquire ledger lock: {e}")))?;
Ok(LedgerLockGuard { _lock: lock })
}
pub fn load(project_dir: &Path) -> Result<Self> {
let path = Self::ledger_path(project_dir);
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&path).map_err(|e| FnoxError::ConfigReadFailed {
path: path.clone(),
source: e,
})?;
let ledger: Self = toml_edit::de::from_str(&content)
.map_err(|e| FnoxError::ConfigParseError { source: e })?;
Ok(ledger)
}
pub fn save(&self, project_dir: &Path) -> Result<()> {
let path = Self::ledger_path(project_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| FnoxError::CreateDirFailed {
path: parent.to_path_buf(),
source: e,
})?;
}
let cutoff = Utc::now() - chrono::Duration::hours(24);
let mut compacted = self.clone();
compacted.leases.retain(|r| {
if r.revoked {
return match r.expires_at {
Some(exp) => exp > cutoff,
None => r.created_at > cutoff,
};
}
match r.expires_at {
Some(exp) => exp > cutoff,
None => r.created_at > cutoff,
}
});
let content = toml_edit::ser::to_string_pretty(&compacted)
.map_err(|e| FnoxError::ConfigSerializeError { source: e })?;
let tmp_path = path.with_extension("toml.tmp");
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, content.as_bytes()))
.map_err(|e| FnoxError::ConfigWriteFailed {
path: tmp_path.clone(),
source: e,
})?;
}
#[cfg(not(unix))]
fs::write(&tmp_path, &content).map_err(|e| FnoxError::ConfigWriteFailed {
path: tmp_path.clone(),
source: e,
})?;
fs::rename(&tmp_path, &path).map_err(|e| FnoxError::ConfigWriteFailed {
path: path.clone(),
source: e,
})?;
Ok(())
}
pub fn add(&mut self, record: LeaseRecord) {
self.leases.push(record);
}
pub fn mark_revoked(&mut self, lease_id: &str) -> bool {
for record in &mut self.leases {
if record.lease_id == lease_id {
record.revoked = true;
record.cached_credentials = None;
record.encryption_provider = None;
return true;
}
}
false
}
pub fn active_leases(&self) -> Vec<&LeaseRecord> {
let now = Utc::now();
self.leases
.iter()
.filter(|r| !r.revoked && r.expires_at.is_none_or(|exp| exp > now))
.collect()
}
pub fn expired_leases(&self) -> Vec<&LeaseRecord> {
let now = Utc::now();
self.leases
.iter()
.filter(|r| !r.revoked && r.expires_at.is_some_and(|exp| exp <= now))
.collect()
}
pub fn find(&self, lease_id: &str) -> Option<&LeaseRecord> {
self.leases.iter().find(|r| r.lease_id == lease_id)
}
pub fn find_reusable(&self, backend_name: &str, config_hash: &str) -> Option<&LeaseRecord> {
self.leases
.iter()
.filter(|r| {
r.backend_name == backend_name
&& r.is_reusable()
&& r.config_hash.as_deref().is_none_or(|h| h == config_hash)
})
.max_by_key(|r| match r.expires_at {
None => DateTime::<Utc>::MAX_UTC,
Some(exp) => exp,
})
}
}
impl LeaseRecord {
pub fn is_reusable(&self) -> bool {
if self.revoked || self.cached_credentials.is_none() {
return false;
}
match self.expires_at {
Some(exp) => {
let buffer = chrono::Duration::seconds(LEASE_REUSE_BUFFER_SECS);
exp - buffer > Utc::now()
}
None => true, }
}
}
#[derive(Default)]
pub struct TempEnvGuard {
pub keys: Vec<String>,
}
impl Drop for TempEnvGuard {
fn drop(&mut self) {
for key in &self.keys {
unsafe { std::env::remove_var(key) };
}
}
}
pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
let s = s.trim();
let mut total_secs: u64 = 0;
let mut current_num = String::new();
for c in s.chars() {
if c.is_ascii_digit() {
current_num.push(c);
} else {
let num: u64 = current_num
.parse()
.map_err(|_| FnoxError::Config(format!("Invalid duration: '{s}'")))?;
current_num.clear();
match c {
's' => total_secs += num,
'm' => total_secs += num * 60,
'h' => total_secs += num * 3600,
'd' => total_secs += num * 86400,
_ => {
return Err(FnoxError::Config(format!(
"Invalid duration unit '{c}' in '{s}'. Use s, m, h, or d"
)));
}
}
}
}
if !current_num.is_empty() {
let num: u64 = current_num
.parse()
.map_err(|_| FnoxError::Config(format!("Invalid duration: '{s}'")))?;
total_secs += num;
}
if total_secs == 0 {
return Err(FnoxError::Config(
"Duration must be greater than 0".to_string(),
));
}
Ok(std::time::Duration::from_secs(total_secs))
}
pub enum EncryptionProviderResult {
NotConfigured,
Available(String, Box<dyn providers::Provider>),
Unavailable(String, FnoxError),
}
pub async fn find_encryption_provider(config: &Config, profile: &str) -> EncryptionProviderResult {
let provider_name = match config.get_default_provider(profile) {
Ok(Some(name)) => name,
_ => return EncryptionProviderResult::NotConfigured,
};
let providers_map = config.get_providers(profile);
let provider_config = match providers_map.get(&provider_name) {
Some(c) => c,
None => return EncryptionProviderResult::NotConfigured,
};
let provider =
match providers::get_provider_resolved(config, profile, &provider_name, provider_config)
.await
{
Ok(p) => p,
Err(e) => {
return EncryptionProviderResult::Unavailable(provider_name, e);
}
};
if provider
.capabilities()
.contains(&ProviderCapability::Encryption)
{
EncryptionProviderResult::Available(provider_name, provider)
} else {
EncryptionProviderResult::NotConfigured
}
}
#[allow(clippy::too_many_arguments)]
fn record_lease(
ledger: &mut LeaseLedger,
result: &crate::lease_backends::Lease,
backend_name: &str,
label: &str,
config_hash: String,
cached_credentials: Option<IndexMap<String, String>>,
encryption_provider: Option<String>,
project_dir: &Path,
) {
ledger.add(LeaseRecord {
lease_id: result.lease_id.clone(),
backend_name: backend_name.to_string(),
label: label.to_string(),
created_at: Utc::now(),
expires_at: result.expires_at,
revoked: false,
cached_credentials,
encryption_provider,
config_hash: Some(config_hash),
});
if let Err(save_err) = ledger.save(project_dir) {
tracing::warn!(
"Lease '{}' created for backend '{}' but ledger save failed: {}. \
This lease is untracked and must be revoked manually.",
result.lease_id,
backend_name,
save_err
);
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_and_record_lease(
backend: &dyn crate::lease_backends::LeaseBackend,
backend_name: &str,
label: &str,
duration: std::time::Duration,
config_hash: String,
config: &Config,
profile: &str,
ledger: &mut LeaseLedger,
project_dir: &Path,
) -> Result<crate::lease_backends::Lease> {
let result = backend.create_lease(duration, label).await?;
let (cached_credentials, encryption_provider) =
cache_credentials(config, profile, &result.credentials, &result.lease_id).await;
record_lease(
ledger,
&result,
backend_name,
label,
config_hash,
cached_credentials,
encryption_provider,
project_dir,
);
Ok(result)
}
pub fn set_secrets_as_env(
resolved_secrets: &IndexMap<String, Option<String>>,
profile_secrets: &IndexMap<String, crate::config::SecretConfig>,
guard: &mut TempEnvGuard,
) -> Result<Vec<tempfile::NamedTempFile>> {
let mut temp_files = Vec::new();
for (key, value) in resolved_secrets {
if let Some(value) = value {
let env_value = if profile_secrets.get(key).is_some_and(|sc| sc.as_file) {
let temp_file = crate::temp_file_secrets::create_ephemeral_secret_file(key, value)?;
let path = temp_file.path().to_string_lossy().to_string();
temp_files.push(temp_file);
path
} else {
value.clone()
};
unsafe { std::env::set_var(key, &env_value) };
guard.keys.push(key.clone());
}
}
Ok(temp_files)
}
pub async fn encrypt_credentials(
provider: &dyn providers::Provider,
credentials: &IndexMap<String, String>,
) -> Result<IndexMap<String, String>> {
let mut encrypted = IndexMap::new();
for (key, value) in credentials {
let enc = provider.encrypt(value).await?;
encrypted.insert(key.clone(), enc);
}
Ok(encrypted)
}
pub async fn decrypt_credentials(
provider: &dyn providers::Provider,
cached: &IndexMap<String, String>,
) -> Result<IndexMap<String, String>> {
let mut decrypted = IndexMap::new();
for (key, value) in cached {
let dec = provider.get_secret(value).await?;
decrypted.insert(key.clone(), dec);
}
Ok(decrypted)
}
pub async fn cache_credentials(
config: &Config,
profile: &str,
credentials: &IndexMap<String, String>,
lease_id: &str,
) -> (Option<IndexMap<String, String>>, Option<String>) {
match find_encryption_provider(config, profile).await {
EncryptionProviderResult::Available(enc_name, provider) => {
match encrypt_credentials(provider.as_ref(), credentials).await {
Ok(encrypted) => {
tracing::debug!("Caching encrypted credentials for lease '{}'", lease_id);
(Some(encrypted), Some(enc_name))
}
Err(e) => {
tracing::warn!(
"Failed to encrypt credentials for caching: {}, skipping cache",
e
);
(None, None)
}
}
}
EncryptionProviderResult::Unavailable(enc_name, e) => {
tracing::warn!(
"Encryption provider '{}' configured but unavailable: {}, skipping credential cache",
enc_name,
e
);
(None, None)
}
EncryptionProviderResult::NotConfigured => {
tracing::debug!(
"No encryption provider, caching plaintext credentials for lease '{}'",
lease_id
);
(Some(credentials.clone()), None)
}
}
}
pub struct CachedEntry {
pub credentials: IndexMap<String, String>,
pub encryption_provider: Option<String>,
pub lease_id: String,
}
pub fn find_cached_entry(
ledger: &LeaseLedger,
name: &str,
config_hash: &str,
) -> Option<CachedEntry> {
let cached_lease = ledger.find_reusable(name, config_hash)?;
let cached_creds = cached_lease.cached_credentials.as_ref()?;
Some(CachedEntry {
credentials: cached_creds.clone(),
encryption_provider: cached_lease.encryption_provider.clone(),
lease_id: cached_lease.lease_id.clone(),
})
}
pub async fn resolve_cached_entry(
entry: CachedEntry,
config: &Config,
profile: &str,
backend_name: &str,
) -> Option<IndexMap<String, String>> {
if let Some(ref enc_provider_name) = entry.encryption_provider {
try_decrypt_cached(
config,
profile,
enc_provider_name,
&entry.credentials,
&entry.lease_id,
backend_name,
)
.await
} else {
tracing::debug!(
"Reusing cached plaintext lease '{}' for backend '{}'",
entry.lease_id,
backend_name
);
Some(entry.credentials)
}
}
pub async fn try_decrypt_cached(
config: &Config,
profile: &str,
enc_provider_name: &str,
cached_creds: &IndexMap<String, String>,
lease_id: &str,
backend_name: &str,
) -> Option<IndexMap<String, String>> {
match find_encryption_provider(config, profile).await {
EncryptionProviderResult::Available(found_name, provider)
if found_name == enc_provider_name =>
{
match decrypt_credentials(provider.as_ref(), cached_creds).await {
Ok(decrypted) => {
tracing::debug!(
"Reusing cached encrypted lease '{}' for backend '{}'",
lease_id,
backend_name
);
Some(decrypted)
}
Err(e) => {
tracing::warn!(
"Failed to decrypt cached lease '{}': {}, creating fresh lease",
lease_id,
e
);
None
}
}
}
_ => {
tracing::warn!(
"Encryption provider '{}' not available for cached lease '{}', creating fresh lease",
enc_provider_name,
lease_id
);
None
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn resolve_lease(
name: &str,
lease_config: &crate::lease_backends::LeaseBackendConfig,
config: &Config,
profile: &str,
project_dir: &Path,
prereq_missing: Option<&str>,
label_prefix: &str,
skip_cache: bool,
) -> Result<IndexMap<String, String>> {
let config_hash = lease_config.config_hash();
if !skip_cache {
let cached_entry = {
let _lock = LeaseLedger::lock(project_dir)?;
let ledger = LeaseLedger::load(project_dir)?;
find_cached_entry(&ledger, name, &config_hash)
};
if let Some(entry) = cached_entry
&& let Some(creds) = resolve_cached_entry(entry, config, profile, name).await
{
return Ok(creds);
}
}
if let Some(missing) = prereq_missing {
return Err(FnoxError::Config(format!(
"Lease '{}': no usable cached credentials and \
prerequisites are missing: {}\n\
Run 'fnox lease create -i {}' to set up credentials interactively.",
name, missing, name
)));
}
let backend = lease_config.create_backend()?;
let duration_str = lease_config.duration().unwrap_or(DEFAULT_LEASE_DURATION);
let duration = parse_duration(duration_str)?;
let max_duration = backend.max_lease_duration();
if duration > max_duration {
return Err(FnoxError::Config(format!(
"Lease duration '{}' for '{}' exceeds maximum {:?}",
duration_str, name, max_duration
)));
}
let label = format!("fnox-{}-{}", label_prefix, name);
let result = backend.create_lease(duration, &label).await?;
tracing::debug!(
"Created lease '{}' for backend '{}' (expires {:?})",
result.lease_id,
name,
result.expires_at
);
let (cached_credentials, encryption_provider) =
cache_credentials(config, profile, &result.credentials, &result.lease_id).await;
{
let _lock = LeaseLedger::lock(project_dir)?;
let mut ledger = LeaseLedger::load(project_dir)?;
record_lease(
&mut ledger,
&result,
name,
&label,
config_hash,
cached_credentials,
encryption_provider,
project_dir,
);
}
Ok(result.credentials)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("15m").unwrap().as_secs(), 900);
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h").unwrap().as_secs(), 3600);
}
#[test]
fn test_parse_duration_combined() {
assert_eq!(parse_duration("2h30m").unwrap().as_secs(), 9000);
}
#[test]
fn test_parse_duration_seconds() {
assert_eq!(parse_duration("30s").unwrap().as_secs(), 30);
}
#[test]
fn test_parse_duration_bare_number() {
assert_eq!(parse_duration("300").unwrap().as_secs(), 300);
}
#[test]
fn test_parse_duration_invalid() {
assert!(parse_duration("").is_err());
assert!(parse_duration("0m").is_err());
assert!(parse_duration("abc").is_err());
}
}