use crate::types::{Hook, Hooks};
use crate::{Error, Result};
use chrono::{DateTime, Utc};
use fs4::tokio::AsyncFileExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path, PathBuf};
use tokio::fs;
use tokio::fs::OpenOptions;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{debug, info, warn};
const CI_VARS: &[&str] = &[
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BITBUCKET_PIPELINES",
"AZURE_PIPELINES",
"TF_BUILD",
"DRONE",
"TEAMCITY_VERSION",
];
#[must_use]
pub fn is_ci() -> bool {
if std::env::var("CI")
.map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false")
.unwrap_or(false)
{
return true;
}
CI_VARS.iter().any(|var| std::env::var(var).is_ok())
}
#[derive(Debug, Clone)]
pub struct ApprovalManager {
approval_file: PathBuf,
approvals: HashMap<String, ApprovalRecord>,
}
impl ApprovalManager {
#[must_use]
pub fn new(approval_file: PathBuf) -> Self {
Self {
approval_file,
approvals: HashMap::new(),
}
}
pub fn default_approval_file() -> Result<PathBuf> {
if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE")
&& !approval_file.is_empty()
{
return Ok(PathBuf::from(approval_file));
}
let base = dirs::state_dir()
.or_else(dirs::data_dir)
.ok_or_else(|| Error::configuration("Could not determine state directory"))?;
Ok(base.join("cuenv").join("approved.json"))
}
pub fn with_default_file() -> Result<Self> {
Ok(Self::new(Self::default_approval_file()?))
}
#[must_use]
pub fn get_approval(&self, directory: &str) -> Option<&ApprovalRecord> {
let path = PathBuf::from(directory);
let dir_key = compute_directory_key(&path);
self.approvals.get(&dir_key)
}
pub async fn load_approvals(&mut self) -> Result<()> {
if !self.approval_file.exists() {
debug!("No approval file found at {}", self.approval_file.display());
return Ok(());
}
let mut file = OpenOptions::new()
.read(true)
.open(&self.approval_file)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(self.approval_file.clone().into_boxed_path()),
operation: "open".to_string(),
})?;
file.lock_shared().map_err(|e| {
Error::configuration(format!(
"Failed to acquire shared lock on approval file: {}",
e
))
})?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(self.approval_file.clone().into_boxed_path()),
operation: "read_to_string".to_string(),
})?;
drop(file);
self.approvals = serde_json::from_str(&contents)
.map_err(|e| Error::serialization(format!("Failed to parse approval file: {e}")))?;
info!("Loaded {} approvals from file", self.approvals.len());
Ok(())
}
pub async fn save_approvals(&self) -> Result<()> {
let canonical_path = validate_and_canonicalize_path(&self.approval_file)?;
if let Some(parent) = canonical_path.parent()
&& !parent.exists()
{
let parent_path = validate_directory_path(parent)?;
fs::create_dir_all(&parent_path)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(parent_path.into()),
operation: "create_dir_all".to_string(),
})?;
}
let contents = serde_json::to_string_pretty(&self.approvals)
.map_err(|e| Error::serialization(format!("Failed to serialize approvals: {e}")))?;
let temp_path = canonical_path.with_extension("tmp");
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temp_path)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(temp_path.clone().into_boxed_path()),
operation: "open".to_string(),
})?;
file.lock_exclusive().map_err(|e| {
Error::configuration(format!(
"Failed to acquire exclusive lock on temp file: {}",
e
))
})?;
file.write_all(contents.as_bytes())
.await
.map_err(|e| Error::Io {
source: e,
path: Some(temp_path.clone().into_boxed_path()),
operation: "write_all".to_string(),
})?;
file.sync_all().await.map_err(|e| Error::Io {
source: e,
path: Some(temp_path.clone().into_boxed_path()),
operation: "sync_all".to_string(),
})?;
drop(file);
fs::rename(&temp_path, &canonical_path)
.await
.map_err(|e| Error::Io {
source: e,
path: Some(canonical_path.clone().into_boxed_path()),
operation: "rename".to_string(),
})?;
debug!("Saved {} approvals to file", self.approvals.len());
Ok(())
}
pub fn is_approved(&self, directory_path: &Path, config_hash: &str) -> Result<bool> {
let dir_key = compute_directory_key(directory_path);
if let Some(approval) = self.approvals.get(&dir_key)
&& approval.config_hash == config_hash
{
if let Some(expires_at) = approval.expires_at
&& Utc::now() > expires_at
{
warn!("Approval for {} has expired", directory_path.display());
return Ok(false);
}
return Ok(true);
}
Ok(false)
}
pub async fn approve_config(
&mut self,
directory_path: &Path,
config_hash: String,
note: Option<String>,
) -> Result<()> {
let dir_key = compute_directory_key(directory_path);
let approval = ApprovalRecord {
directory_path: directory_path.to_path_buf(),
config_hash,
approved_at: Utc::now(),
expires_at: None, note,
};
self.approvals.insert(dir_key, approval);
self.save_approvals().await?;
info!(
"Approved configuration for directory: {}",
directory_path.display()
);
Ok(())
}
pub async fn revoke_approval(&mut self, directory_path: &Path) -> Result<bool> {
let dir_key = compute_directory_key(directory_path);
if self.approvals.remove(&dir_key).is_some() {
self.save_approvals().await?;
info!(
"Revoked approval for directory: {}",
directory_path.display()
);
Ok(true)
} else {
Ok(false)
}
}
#[must_use]
pub fn list_approved(&self) -> Vec<&ApprovalRecord> {
self.approvals.values().collect()
}
pub async fn cleanup_expired(&mut self) -> Result<usize> {
let now = Utc::now();
let initial_count = self.approvals.len();
self.approvals.retain(|_, approval| {
if let Some(expires_at) = approval.expires_at {
expires_at > now
} else {
true }
});
let removed_count = initial_count - self.approvals.len();
if removed_count > 0 {
self.save_approvals().await?;
info!("Cleaned up {} expired approvals", removed_count);
}
Ok(removed_count)
}
#[must_use]
pub fn contains_key(&self, directory_path: &Path) -> bool {
let dir_key = compute_directory_key(directory_path);
self.approvals.contains_key(&dir_key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApprovalRecord {
pub directory_path: PathBuf,
pub config_hash: String,
pub approved_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalStatus {
Approved,
RequiresApproval {
current_hash: String,
},
NotApproved {
current_hash: String,
},
}
#[derive(Debug, Serialize)]
struct ApprovalHashInput {
#[serde(skip_serializing_if = "Option::is_none")]
hooks: Option<HooksForHash>,
}
#[derive(Debug, Serialize)]
struct HooksForHash {
#[serde(skip_serializing_if = "Option::is_none", rename = "onEnter")]
on_enter: Option<BTreeMap<String, Hook>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "onExit")]
on_exit: Option<BTreeMap<String, Hook>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "prePush")]
pre_push: Option<BTreeMap<String, Hook>>,
}
impl HooksForHash {
fn from_hooks(hooks: &Hooks) -> Self {
Self {
on_enter: hooks.on_enter.as_ref().map(sorted_hooks_map),
on_exit: hooks.on_exit.as_ref().map(sorted_hooks_map),
pre_push: hooks.pre_push.as_ref().map(sorted_hooks_map),
}
}
}
fn sorted_hooks_map(map: &HashMap<String, Hook>) -> BTreeMap<String, Hook> {
map.iter()
.map(|(name, hook)| (name.clone(), hook.clone()))
.collect()
}
pub fn check_approval_status(
manager: &ApprovalManager,
directory_path: &Path,
hooks: Option<&Hooks>,
) -> Result<ApprovalStatus> {
if is_ci() {
debug!(
"Auto-approving hooks in CI environment for {}",
directory_path.display()
);
return Ok(ApprovalStatus::Approved);
}
check_approval_status_core(manager, directory_path, hooks)
}
fn check_approval_status_core(
manager: &ApprovalManager,
directory_path: &Path,
hooks: Option<&Hooks>,
) -> Result<ApprovalStatus> {
let current_hash = compute_approval_hash(hooks);
if manager.is_approved(directory_path, ¤t_hash)? {
Ok(ApprovalStatus::Approved)
} else {
if manager.contains_key(directory_path) {
Ok(ApprovalStatus::RequiresApproval { current_hash })
} else {
Ok(ApprovalStatus::NotApproved { current_hash })
}
}
}
#[must_use]
pub fn compute_approval_hash(hooks: Option<&Hooks>) -> String {
let mut hasher = Sha256::new();
let hooks_for_hash = hooks.and_then(|h| {
let hfh = HooksForHash::from_hooks(h);
if hfh.on_enter.is_none() && hfh.on_exit.is_none() && hfh.pre_push.is_none() {
None
} else {
Some(hfh)
}
});
let hooks_only = ApprovalHashInput {
hooks: hooks_for_hash,
};
let canonical = serde_json::to_string(&hooks_only).unwrap_or_default();
hasher.update(canonical.as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
#[must_use]
pub fn compute_directory_key(path: &Path) -> String {
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut hasher = Sha256::new();
hasher.update(canonical_path.to_string_lossy().as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
fn validate_and_canonicalize_path(path: &Path) -> Result<PathBuf> {
for component in path.components() {
match component {
Component::Normal(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir
| Component::Prefix(_) => {}
}
}
if path.exists() {
std::fs::canonicalize(path)
.map_err(|e| Error::configuration(format!("Failed to canonicalize path: {}", e)))
} else {
if let Some(parent) = path.parent() {
if parent.exists() {
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
Error::configuration(format!("Failed to canonicalize parent path: {}", e))
})?;
if let Some(file_name) = path.file_name() {
Ok(canonical_parent.join(file_name))
} else {
Err(Error::configuration("Invalid file path"))
}
} else {
validate_path_structure(path)?;
Ok(path.to_path_buf())
}
} else {
validate_path_structure(path)?;
Ok(path.to_path_buf())
}
}
}
fn validate_directory_path(path: &Path) -> Result<PathBuf> {
validate_path_structure(path)?;
Ok(path.to_path_buf())
}
fn validate_path_structure(path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
if path_str.contains('\0') {
return Err(Error::configuration("Path contains null bytes"));
}
let suspicious_patterns = [
"../../../", "..\\..\\..\\", "%2e%2e", "..;/", ];
for pattern in &suspicious_patterns {
if path_str.contains(pattern) {
return Err(Error::configuration(format!(
"Path contains suspicious pattern: {}",
pattern
)));
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ConfigSummary {
pub has_hooks: bool,
pub hook_count: usize,
}
impl ConfigSummary {
#[must_use]
pub fn from_hooks(hooks: Option<&Hooks>) -> Self {
let mut summary = Self {
has_hooks: false,
hook_count: 0,
};
if let Some(hooks) = hooks {
let on_enter_count = hooks.on_enter.as_ref().map_or(0, |map| map.len());
let on_exit_count = hooks.on_exit.as_ref().map_or(0, |map| map.len());
let pre_push_count = hooks.pre_push.as_ref().map_or(0, |map| map.len());
summary.hook_count = on_enter_count + on_exit_count + pre_push_count;
summary.has_hooks = summary.hook_count > 0;
}
summary
}
#[must_use]
pub fn description(&self) -> String {
if !self.has_hooks {
"no hooks".to_string()
} else if self.hook_count == 1 {
"1 hook".to_string()
} else {
format!("{} hooks", self.hook_count)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_hook(command: &str, args: &[&str]) -> Hook {
Hook {
order: 100,
propagate: false,
command: command.to_string(),
args: args.iter().map(|arg| (*arg).to_string()).collect(),
dir: None,
inputs: vec![],
source: None,
}
}
#[tokio::test]
async fn test_approval_manager_operations() {
let temp_dir = TempDir::new().unwrap();
let approval_file = temp_dir.path().join("approvals.json");
let mut manager = ApprovalManager::new(approval_file);
let directory = Path::new("/test/directory");
let config_hash = "test_hash_123".to_string();
assert!(!manager.is_approved(directory, &config_hash).unwrap());
manager
.approve_config(
directory,
config_hash.clone(),
Some("Test approval".to_string()),
)
.await
.unwrap();
assert!(manager.is_approved(directory, &config_hash).unwrap());
assert!(!manager.is_approved(directory, "different_hash").unwrap());
let mut manager2 = ApprovalManager::new(manager.approval_file.clone());
manager2.load_approvals().await.unwrap();
assert!(manager2.is_approved(directory, &config_hash).unwrap());
let revoked = manager2.revoke_approval(directory).await.unwrap();
assert!(revoked);
assert!(!manager2.is_approved(directory, &config_hash).unwrap());
}
#[test]
fn test_approval_hash_consistency() {
let mut hooks_map = HashMap::new();
hooks_map.insert("setup".to_string(), make_hook("echo", &["hello"]));
let hooks = Hooks {
on_enter: Some(hooks_map.clone()),
on_exit: None,
pre_push: None,
};
let hash1 = compute_approval_hash(Some(&hooks));
let hash2 = compute_approval_hash(Some(&hooks));
assert_eq!(hash1, hash2, "Same hooks should produce same hash");
let mut hooks_map2 = HashMap::new();
hooks_map2.insert("setup".to_string(), make_hook("echo", &["world"]));
let hooks2 = Hooks {
on_enter: Some(hooks_map2),
on_exit: None,
pre_push: None,
};
let hash3 = compute_approval_hash(Some(&hooks2));
assert_ne!(
hash1, hash3,
"Different hooks should produce different hash"
);
}
#[test]
fn test_approval_hash_no_hooks() {
let hash1 = compute_approval_hash(None);
let hash2 = compute_approval_hash(None);
assert_eq!(hash1, hash2, "No hooks should produce consistent hash");
let empty_hooks = Hooks {
on_enter: None,
on_exit: None,
pre_push: None,
};
let hash3 = compute_approval_hash(Some(&empty_hooks));
assert_eq!(hash1, hash3, "Empty hooks should be same as no hooks");
}
#[test]
fn test_config_summary() {
let mut on_enter = HashMap::new();
on_enter.insert("npm".to_string(), make_hook("npm", &["install"]));
on_enter.insert(
"docker".to_string(),
make_hook("docker-compose", &["up", "-d"]),
);
let mut on_exit = HashMap::new();
on_exit.insert("docker".to_string(), make_hook("docker-compose", &["down"]));
let hooks = Hooks {
on_enter: Some(on_enter),
on_exit: Some(on_exit),
pre_push: None,
};
let summary = ConfigSummary::from_hooks(Some(&hooks));
assert!(summary.has_hooks);
assert_eq!(summary.hook_count, 3);
let description = summary.description();
assert!(description.contains("3 hooks"));
}
#[test]
fn test_approval_status() {
let mut manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
let directory = Path::new("/test/dir");
let hooks = Hooks {
on_enter: None,
on_exit: None,
pre_push: None,
};
let status = check_approval_status_core(&manager, directory, Some(&hooks)).unwrap();
assert!(matches!(status, ApprovalStatus::NotApproved { .. }));
let different_hash = "different_hash".to_string();
manager.approvals.insert(
compute_directory_key(directory),
ApprovalRecord {
directory_path: directory.to_path_buf(),
config_hash: different_hash,
approved_at: Utc::now(),
expires_at: None,
note: None,
},
);
let status = check_approval_status_core(&manager, directory, Some(&hooks)).unwrap();
assert!(matches!(status, ApprovalStatus::RequiresApproval { .. }));
let correct_hash = compute_approval_hash(Some(&hooks));
manager.approvals.insert(
compute_directory_key(directory),
ApprovalRecord {
directory_path: directory.to_path_buf(),
config_hash: correct_hash,
approved_at: Utc::now(),
expires_at: None,
note: None,
},
);
let status = check_approval_status_core(&manager, directory, Some(&hooks)).unwrap();
assert!(matches!(status, ApprovalStatus::Approved));
}
#[test]
fn test_path_validation() {
assert!(validate_path_structure(Path::new("/home/user/test")).is_ok());
assert!(validate_path_structure(Path::new("./relative/path")).is_ok());
assert!(validate_path_structure(Path::new("file.txt")).is_ok());
let path_with_null = PathBuf::from("/test\0/path");
assert!(validate_path_structure(&path_with_null).is_err());
assert!(validate_path_structure(Path::new("../../../etc/passwd")).is_err());
assert!(validate_path_structure(Path::new("..\\..\\..\\windows\\system32")).is_err());
assert!(validate_path_structure(Path::new("/test/%2e%2e/passwd")).is_err());
assert!(validate_path_structure(Path::new("..;/etc/passwd")).is_err());
}
#[test]
fn test_validate_and_canonicalize_path() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
std::fs::write(&test_file, "test").unwrap();
let result = validate_and_canonicalize_path(&test_file).unwrap();
assert!(result.is_absolute());
assert!(result.exists());
let new_file = temp_dir.path().join("new_file.txt");
let result = validate_and_canonicalize_path(&new_file).unwrap();
assert!(result.ends_with("new_file.txt"));
let nested_new = temp_dir.path().join("subdir/newfile.txt");
let result = validate_and_canonicalize_path(&nested_new);
assert!(result.is_ok()); }
#[tokio::test]
async fn test_approval_file_corruption_recovery() {
let temp_dir = TempDir::new().unwrap();
let approval_file = temp_dir.path().join("approvals.json");
std::fs::write(&approval_file, "{invalid json}").unwrap();
let mut manager = ApprovalManager::new(approval_file.clone());
let result = manager.load_approvals().await;
assert!(
result.is_err(),
"Expected error when loading corrupted JSON"
);
assert_eq!(manager.approvals.len(), 0);
let directory = Path::new("/test/dir");
manager
.approve_config(directory, "test_hash".to_string(), None)
.await
.unwrap();
let mut manager2 = ApprovalManager::new(approval_file);
manager2.load_approvals().await.unwrap();
assert_eq!(manager2.approvals.len(), 1);
}
#[tokio::test]
async fn test_approval_expiration() {
let temp_dir = TempDir::new().unwrap();
let approval_file = temp_dir.path().join("approvals.json");
let mut manager = ApprovalManager::new(approval_file);
let directory = Path::new("/test/expire");
let config_hash = "expire_hash".to_string();
let expired_approval = ApprovalRecord {
directory_path: directory.to_path_buf(),
config_hash: config_hash.clone(),
approved_at: Utc::now() - chrono::Duration::hours(2),
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
note: Some("Expired approval".to_string()),
};
manager
.approvals
.insert(compute_directory_key(directory), expired_approval);
assert!(!manager.is_approved(directory, &config_hash).unwrap());
let removed = manager.cleanup_expired().await.unwrap();
assert_eq!(removed, 1);
assert_eq!(manager.approvals.len(), 0);
}
#[test]
fn test_is_ci_with_ci_env_var() {
temp_env::with_var("CI", Some("true"), || {
assert!(is_ci());
});
temp_env::with_var("CI", Some("1"), || {
assert!(is_ci());
});
temp_env::with_var("CI", Some("yes"), || {
assert!(is_ci());
});
temp_env::with_var("CI", Some("false"), || {
temp_env::with_vars_unset(
vec![
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BITBUCKET_PIPELINES",
"AZURE_PIPELINES",
"TF_BUILD",
"DRONE",
"TEAMCITY_VERSION",
],
|| {
assert!(!is_ci());
},
);
});
temp_env::with_var("CI", Some("0"), || {
temp_env::with_vars_unset(
vec![
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BITBUCKET_PIPELINES",
"AZURE_PIPELINES",
"TF_BUILD",
"DRONE",
"TEAMCITY_VERSION",
],
|| {
assert!(!is_ci());
},
);
});
}
#[test]
fn test_is_ci_with_provider_specific_vars() {
temp_env::with_var_unset("CI", || {
temp_env::with_var("GITHUB_ACTIONS", Some("true"), || {
assert!(is_ci());
});
});
temp_env::with_var_unset("CI", || {
temp_env::with_var("GITLAB_CI", Some("true"), || {
assert!(is_ci());
});
});
temp_env::with_var_unset("CI", || {
temp_env::with_var("BUILDKITE", Some("true"), || {
assert!(is_ci());
});
});
temp_env::with_var_unset("CI", || {
temp_env::with_var("JENKINS_URL", Some("http://jenkins.example.com"), || {
assert!(is_ci());
});
});
}
#[test]
fn test_is_ci_not_detected() {
temp_env::with_vars_unset(
vec![
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BITBUCKET_PIPELINES",
"AZURE_PIPELINES",
"TF_BUILD",
"DRONE",
"TEAMCITY_VERSION",
],
|| {
assert!(!is_ci());
},
);
}
#[test]
fn test_approval_status_auto_approved_in_ci() {
let manager = ApprovalManager::new(PathBuf::from("/tmp/test"));
let directory = Path::new("/test/ci_dir");
let mut hooks_map = HashMap::new();
hooks_map.insert("setup".to_string(), make_hook("echo", &["hello"]));
let hooks = Hooks {
on_enter: Some(hooks_map),
on_exit: None,
pre_push: None,
};
temp_env::with_var("CI", Some("true"), || {
let status = check_approval_status(&manager, directory, Some(&hooks)).unwrap();
assert!(
matches!(status, ApprovalStatus::Approved),
"Hooks should be auto-approved in CI"
);
});
temp_env::with_vars_unset(
vec![
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BITBUCKET_PIPELINES",
"AZURE_PIPELINES",
"TF_BUILD",
"DRONE",
"TEAMCITY_VERSION",
],
|| {
let status = check_approval_status(&manager, directory, Some(&hooks)).unwrap();
assert!(
matches!(status, ApprovalStatus::NotApproved { .. }),
"Hooks should require approval outside CI"
);
},
);
}
}