use anyhow::Result;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::SystemTime;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HookEnvSession {
#[serde(default)]
pub dir: Option<PathBuf>,
#[serde(default)]
pub config_path: Option<PathBuf>,
#[serde(default)]
pub config_mtime: Option<u128>,
#[serde(default)]
pub secret_hashes: IndexMap<String, String>,
#[serde(default)]
pub hash_key: [u8; 32],
#[serde(default)]
pub env_var_hash: String,
#[serde(default)]
pub config_files_hash: String,
#[serde(default)]
pub temp_files: HashMap<String, String>,
}
pub static PREV_SESSION: LazyLock<HookEnvSession> = LazyLock::new(|| {
if let Ok(encoded) = std::env::var("__FNOX_SESSION")
&& let Ok(session) = decode_session(&encoded)
{
return session;
}
HookEnvSession::default()
});
impl HookEnvSession {
pub fn new(
dir: Option<PathBuf>,
config_path: Option<PathBuf>,
loaded_secrets: HashMap<String, String>,
temp_files: HashMap<String, String>,
) -> Result<Self> {
let config_mtime = if let Some(ref path) = config_path {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_millis())
} else {
None
};
let env_var_hash = hash_fnox_env_vars();
let config_files_hash = if let Some(ref d) = dir {
let configs = collect_config_files(d);
hash_config_files(&configs)
} else {
String::new()
};
use blake3::Hasher;
let hash_key = *Hasher::new()
.update(b"fnox-session-")
.update(
&std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
)
.update(&std::process::id().to_le_bytes())
.finalize()
.as_bytes();
let secret_hashes: IndexMap<String, String> = loaded_secrets
.iter()
.map(|(k, v)| (k.clone(), hash_secret_with_key(&hash_key, k, v)))
.collect();
Ok(Self {
dir,
config_path,
config_mtime,
secret_hashes,
hash_key,
env_var_hash,
config_files_hash,
temp_files,
})
}
pub fn encode(&self) -> Result<String> {
let bytes = rmp_serde::to_vec(self)?;
let compressed = miniz_oxide::deflate::compress_to_vec(&bytes, 6);
Ok(data_encoding::BASE64.encode(&compressed))
}
}
fn decode_session(encoded: &str) -> Result<HookEnvSession> {
let compressed = data_encoding::BASE64.decode(encoded.as_bytes())?;
let bytes = miniz_oxide::inflate::decompress_to_vec(&compressed)
.map_err(|e| anyhow::anyhow!("failed to decompress session: {:?}", e))?;
let session = rmp_serde::from_slice(&bytes)?;
Ok(session)
}
fn hash_secret_with_key(hash_key: &[u8; 32], key: &str, value: &str) -> String {
let mut hasher = blake3::Hasher::new_keyed(hash_key);
hasher.update(key.as_bytes());
hasher.update(b"\x00"); hasher.update(value.as_bytes());
hasher.finalize().to_hex().to_string()
}
pub fn hash_secret_value_with_session(session: &HookEnvSession, key: &str, value: &str) -> String {
hash_secret_with_key(&session.hash_key, key, value)
}
pub fn should_exit_early() -> bool {
if has_directory_changed() {
tracing::debug!("directory changed, must run hook-env");
return false;
}
if has_config_been_modified() {
tracing::debug!("fnox.toml modified, must run hook-env");
return false;
}
if has_fnox_env_vars_changed() {
tracing::debug!("FNOX_* env vars changed, must run hook-env");
return false;
}
tracing::debug!("no changes detected, exiting early");
true
}
fn has_directory_changed() -> bool {
let current_dir = std::env::current_dir().ok();
PREV_SESSION.dir != current_dir
}
fn has_config_been_modified() -> bool {
let current_dir = match std::env::current_dir() {
Ok(dir) => dir,
Err(_) => return true, };
let current_configs = collect_config_files(¤t_dir);
let current_hash = hash_config_files(¤t_configs);
if PREV_SESSION.config_files_hash.is_empty() {
let had_config = PREV_SESSION.config_path.is_some();
let has_config = !current_configs.is_empty();
return had_config || has_config;
}
current_hash != PREV_SESSION.config_files_hash
}
fn collect_config_files(start_dir: &Path) -> Vec<(PathBuf, u128)> {
use crate::config::Config;
let mut configs = Vec::new();
let mut current = start_dir.to_path_buf();
let profile_name = crate::settings::Settings::get().profile.clone();
let filenames = crate::config::all_config_filenames(Some(&profile_name));
loop {
for filename in &filenames {
let config_path = current.join(filename);
if let Ok(metadata) = std::fs::metadata(&config_path)
&& let Ok(modified) = metadata.modified()
&& let Ok(duration) = modified.duration_since(SystemTime::UNIX_EPOCH)
{
configs.push((config_path, duration.as_millis()));
}
}
if !current.pop() {
break;
}
}
let global = Config::global_config_path();
if let Ok(metadata) = std::fs::metadata(&global)
&& let Ok(modified) = metadata.modified()
&& let Ok(duration) = modified.duration_since(SystemTime::UNIX_EPOCH)
{
configs.push((global, duration.as_millis()));
}
configs
}
fn hash_config_files(configs: &[(PathBuf, u128)]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for (path, mtime) in configs {
path.hash(&mut hasher);
mtime.hash(&mut hasher);
}
format!("{:x}", hasher.finish())
}
fn has_fnox_env_vars_changed() -> bool {
let current_hash = hash_fnox_env_vars();
current_hash != PREV_SESSION.env_var_hash
}
fn hash_fnox_env_vars() -> String {
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
let mut vars: BTreeMap<String, String> = BTreeMap::new();
for (key, value) in std::env::vars() {
if key.starts_with("FNOX_") {
vars.insert(key, value);
}
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
vars.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn find_config() -> Option<PathBuf> {
use crate::config::{Config, all_config_filenames};
let profile = crate::settings::Settings::get().profile.clone();
let filenames = all_config_filenames(Some(&profile));
let mut current = std::env::current_dir().ok()?;
loop {
for filename in &filenames {
let path = current.join(filename);
if path.exists() {
return Some(path);
}
}
if !current.pop() {
break;
}
}
let global = Config::global_config_path();
if global.is_file() {
return Some(global);
}
None
}