use indexmap::IndexSet;
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DotfileProtectionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub require_explicit_confirmation: bool,
#[serde(default = "default_true")]
pub audit_logging_enabled: bool,
#[serde(default = "default_audit_log_path")]
pub audit_log_path: String,
#[serde(default = "default_true")]
pub prevent_cascading_modifications: bool,
#[serde(default = "default_true")]
pub create_backups: bool,
#[serde(default = "default_backup_dir")]
pub backup_directory: String,
#[serde(default = "default_max_backups")]
pub max_backups_per_file: usize,
#[serde(default = "default_true")]
pub preserve_permissions: bool,
#[serde(default)]
pub whitelist: IndexSet<String>,
#[serde(default)]
pub additional_protected_patterns: Vec<String>,
#[serde(default = "default_true")]
pub block_during_automation: bool,
#[serde(default = "default_blocked_operations")]
pub blocked_operations: Vec<String>,
#[serde(default = "default_true")]
pub require_secondary_auth_for_whitelist: bool,
}
impl Default for DotfileProtectionConfig {
fn default() -> Self {
Self {
enabled: default_true(),
require_explicit_confirmation: default_true(),
audit_logging_enabled: default_true(),
audit_log_path: default_audit_log_path(),
prevent_cascading_modifications: default_true(),
create_backups: default_true(),
backup_directory: default_backup_dir(),
max_backups_per_file: default_max_backups(),
preserve_permissions: default_true(),
whitelist: IndexSet::new(),
additional_protected_patterns: Vec::new(),
block_during_automation: default_true(),
blocked_operations: default_blocked_operations(),
require_secondary_auth_for_whitelist: default_true(),
}
}
}
pub const DEFAULT_PROTECTED_DOTFILES: &[&str] = &[
".gitignore",
".gitattributes",
".gitmodules",
".gitconfig",
".git-credentials",
".editorconfig",
".vscode/*",
".idea/*",
".cursor/*",
".env",
".env.local",
".env.development",
".env.production",
".env.test",
".env.*",
".dockerignore",
".docker/*",
".npmignore",
".npmrc",
".nvmrc",
".yarnrc",
".yarnrc.yml",
".pnpmrc",
".prettierrc",
".prettierrc.json",
".prettierrc.yml",
".prettierrc.yaml",
".prettierrc.js",
".prettierrc.cjs",
".prettierignore",
".eslintrc",
".eslintrc.json",
".eslintrc.yml",
".eslintrc.yaml",
".eslintrc.js",
".eslintrc.cjs",
".eslintignore",
".stylelintrc",
".stylelintrc.json",
".babelrc",
".babelrc.json",
".babelrc.js",
".swcrc",
".tsbuildinfo",
".zshrc",
".bashrc",
".bash_profile",
".bash_history",
".bash_logout",
".profile",
".zprofile",
".zshenv",
".zsh_history",
".shrc",
".kshrc",
".cshrc",
".tcshrc",
".fishrc",
".config/fish/*",
".vimrc",
".vim/*",
".nvim/*",
".config/nvim/*",
".emacs",
".emacs.d/*",
".nanorc",
".tmux.conf",
".screenrc",
".ssh/*",
".ssh/config",
".ssh/known_hosts",
".ssh/authorized_keys",
".gnupg/*",
".gpg/*",
".aws/*",
".aws/config",
".aws/credentials",
".azure/*",
".config/gcloud/*",
".kube/*",
".kube/config",
".cargo/*",
".cargo/config.toml",
".cargo/credentials.toml",
".rustup/*",
".gem/*",
".bundle/*",
".pip/*",
".pypirc",
".poetry/*",
".pdm.toml",
".python-version",
".ruby-version",
".node-version",
".go-version",
".tool-versions",
".pgpass",
".my.cnf",
".mongorc.js",
".rediscli_history",
".netrc",
".curlrc",
".wgetrc",
".htaccess",
".htpasswd",
".vtcode/*",
".vtcodegitignore",
".vtcode.toml",
".claude/*",
".claude.json",
".agent/*",
".inputrc",
".dircolors",
".mailrc",
".gitkeep",
".keep",
];
fn default_blocked_operations() -> Vec<String> {
vec![
"dependency_installation".into(),
"code_formatting".into(),
"git_operations".into(),
"project_initialization".into(),
"build_operations".into(),
"test_execution".into(),
"linting".into(),
"auto_fix".into(),
]
}
#[inline]
const fn default_true() -> bool {
true
}
#[inline]
fn default_audit_log_path() -> String {
"~/.vtcode/dotfile_audit.log".into()
}
#[inline]
fn default_backup_dir() -> String {
"~/.vtcode/dotfile_backups".into()
}
#[inline]
const fn default_max_backups() -> usize {
10
}
impl DotfileProtectionConfig {
pub fn is_protected(&self, path: &str) -> bool {
if !self.enabled {
return false;
}
let filename = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let is_dotfile = filename.starts_with('.')
|| path.contains("/.")
|| path.starts_with('.')
|| Self::is_in_dotfile_directory(path);
if !is_dotfile {
return false;
}
for pattern in DEFAULT_PROTECTED_DOTFILES {
if Self::matches_pattern(path, pattern) || Self::matches_pattern(filename, pattern) {
return true;
}
}
for pattern in &self.additional_protected_patterns {
if Self::matches_pattern(path, pattern) || Self::matches_pattern(filename, pattern) {
return true;
}
}
filename.starts_with('.') || Self::is_in_dotfile_directory(path)
}
fn is_in_dotfile_directory(path: &str) -> bool {
let components: Vec<&str> = path.split('/').collect();
for component in &components {
if component.starts_with('.')
&& !component.is_empty()
&& *component != "."
&& *component != ".."
{
return true;
}
}
false
}
pub fn is_whitelisted(&self, path: &str) -> bool {
let filename = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
self.whitelist.contains(path) || self.whitelist.contains(filename)
}
fn matches_pattern(path: &str, pattern: &str) -> bool {
if pattern.contains('*') {
if let Some(prefix) = pattern.strip_suffix("/*") {
path.starts_with(prefix)
|| path.contains(&format!("/{}/", prefix.trim_start_matches('.')))
} else if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1];
path.starts_with(prefix)
} else {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
path.starts_with(parts[0]) && path.ends_with(parts[1])
} else {
path == pattern
}
}
} else {
path == pattern || path.ends_with(&format!("/{}", pattern))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_protection() {
let config = DotfileProtectionConfig::default();
assert!(config.is_protected(".gitignore"));
assert!(config.is_protected(".env"));
assert!(config.is_protected(".env.local"));
assert!(config.is_protected(".bashrc"));
assert!(config.is_protected(".ssh/config"));
assert!(config.is_protected("/home/user/.npmrc"));
assert!(!config.is_protected("README.md"));
assert!(!config.is_protected("src/main.rs"));
}
#[test]
fn test_whitelist() {
let mut config = DotfileProtectionConfig::default();
config.whitelist.insert(".gitignore".into());
assert!(config.is_whitelisted(".gitignore"));
assert!(!config.is_whitelisted(".env"));
}
#[test]
fn test_disabled_protection() {
let config = DotfileProtectionConfig {
enabled: false,
..Default::default()
};
assert!(!config.is_protected(".gitignore"));
assert!(!config.is_protected(".env"));
}
#[test]
fn test_pattern_matching() {
assert!(DotfileProtectionConfig::matches_pattern(
".env.local",
".env.*"
));
assert!(DotfileProtectionConfig::matches_pattern(
".env.production",
".env.*"
));
assert!(DotfileProtectionConfig::matches_pattern(
".vscode/settings.json",
".vscode/*"
));
}
}