1use indexmap::IndexSet;
7use serde::{Deserialize, Serialize};
8
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct DotfileProtectionConfig {
13 #[serde(default = "default_true")]
15 pub enabled: bool,
16
17 #[serde(default = "default_true")]
19 pub require_explicit_confirmation: bool,
20
21 #[serde(default = "default_true")]
23 pub audit_logging_enabled: bool,
24
25 #[serde(default = "default_audit_log_path")]
27 pub audit_log_path: String,
28
29 #[serde(default = "default_true")]
31 pub prevent_cascading_modifications: bool,
32
33 #[serde(default = "default_true")]
35 pub create_backups: bool,
36
37 #[serde(default = "default_backup_dir")]
39 pub backup_directory: String,
40
41 #[serde(default = "default_max_backups")]
43 pub max_backups_per_file: usize,
44
45 #[serde(default = "default_true")]
47 pub preserve_permissions: bool,
48
49 #[serde(default)]
51 pub whitelist: IndexSet<String>,
52
53 #[serde(default)]
55 pub additional_protected_patterns: Vec<String>,
56
57 #[serde(default = "default_true")]
59 pub block_during_automation: bool,
60
61 #[serde(default = "default_blocked_operations")]
63 pub blocked_operations: Vec<String>,
64
65 #[serde(default = "default_true")]
67 pub require_secondary_auth_for_whitelist: bool,
68}
69
70impl Default for DotfileProtectionConfig {
71 fn default() -> Self {
72 Self {
73 enabled: default_true(),
74 require_explicit_confirmation: default_true(),
75 audit_logging_enabled: default_true(),
76 audit_log_path: default_audit_log_path(),
77 prevent_cascading_modifications: default_true(),
78 create_backups: default_true(),
79 backup_directory: default_backup_dir(),
80 max_backups_per_file: default_max_backups(),
81 preserve_permissions: default_true(),
82 whitelist: IndexSet::new(),
83 additional_protected_patterns: Vec::new(),
84 block_during_automation: default_true(),
85 blocked_operations: default_blocked_operations(),
86 require_secondary_auth_for_whitelist: default_true(),
87 }
88 }
89}
90
91pub const DEFAULT_PROTECTED_DOTFILES: &[&str] = &[
96 ".gitignore",
98 ".gitattributes",
99 ".gitmodules",
100 ".gitconfig",
101 ".git-credentials",
102 ".editorconfig",
104 ".vscode/*",
105 ".idea/*",
106 ".cursor/*",
107 ".env",
109 ".env.local",
110 ".env.development",
111 ".env.production",
112 ".env.test",
113 ".env.*",
114 ".dockerignore",
116 ".docker/*",
117 ".npmignore",
119 ".npmrc",
120 ".nvmrc",
121 ".yarnrc",
122 ".yarnrc.yml",
123 ".pnpmrc",
124 ".prettierrc",
126 ".prettierrc.json",
127 ".prettierrc.yml",
128 ".prettierrc.yaml",
129 ".prettierrc.js",
130 ".prettierrc.cjs",
131 ".prettierignore",
132 ".eslintrc",
134 ".eslintrc.json",
135 ".eslintrc.yml",
136 ".eslintrc.yaml",
137 ".eslintrc.js",
138 ".eslintrc.cjs",
139 ".eslintignore",
140 ".stylelintrc",
141 ".stylelintrc.json",
142 ".babelrc",
144 ".babelrc.json",
145 ".babelrc.js",
146 ".swcrc",
147 ".tsbuildinfo",
148 ".zshrc",
150 ".bashrc",
151 ".bash_profile",
152 ".bash_history",
153 ".bash_logout",
154 ".profile",
155 ".zprofile",
156 ".zshenv",
157 ".zsh_history",
158 ".shrc",
159 ".kshrc",
160 ".cshrc",
161 ".tcshrc",
162 ".fishrc",
163 ".config/fish/*",
164 ".vimrc",
166 ".vim/*",
167 ".nvim/*",
168 ".config/nvim/*",
169 ".emacs",
170 ".emacs.d/*",
171 ".nanorc",
172 ".tmux.conf",
174 ".screenrc",
175 ".ssh/*",
177 ".ssh/config",
178 ".ssh/known_hosts",
179 ".ssh/authorized_keys",
180 ".gnupg/*",
181 ".gpg/*",
182 ".aws/*",
184 ".aws/config",
185 ".aws/credentials",
186 ".azure/*",
187 ".config/gcloud/*",
188 ".kube/*",
189 ".kube/config",
190 ".cargo/*",
192 ".cargo/config.toml",
193 ".cargo/credentials.toml",
194 ".rustup/*",
195 ".gem/*",
196 ".bundle/*",
197 ".pip/*",
198 ".pypirc",
199 ".poetry/*",
200 ".pdm.toml",
201 ".python-version",
202 ".ruby-version",
203 ".node-version",
204 ".go-version",
205 ".tool-versions",
206 ".pgpass",
208 ".my.cnf",
209 ".mongorc.js",
210 ".rediscli_history",
211 ".netrc",
213 ".curlrc",
214 ".wgetrc",
215 ".htaccess",
216 ".htpasswd",
217 ".vtcode/*",
219 ".vtcodegitignore",
220 ".vtcode.toml",
221 ".claude/*",
223 ".claude.json",
224 ".agent/*",
225 ".inputrc",
227 ".dircolors",
228 ".mailrc",
229 ".gitkeep",
230 ".keep",
231];
232
233fn default_blocked_operations() -> Vec<String> {
235 vec![
236 "dependency_installation".into(),
237 "code_formatting".into(),
238 "git_operations".into(),
239 "project_initialization".into(),
240 "build_operations".into(),
241 "test_execution".into(),
242 "linting".into(),
243 "auto_fix".into(),
244 ]
245}
246
247#[inline]
248const fn default_true() -> bool {
249 true
250}
251
252#[inline]
253fn default_audit_log_path() -> String {
254 "~/.vtcode/dotfile_audit.log".into()
255}
256
257#[inline]
258fn default_backup_dir() -> String {
259 "~/.vtcode/dotfile_backups".into()
260}
261
262#[inline]
263const fn default_max_backups() -> usize {
264 10
265}
266
267impl DotfileProtectionConfig {
268 pub fn is_protected(&self, path: &str) -> bool {
270 if !self.enabled {
271 return false;
272 }
273
274 let filename = std::path::Path::new(path)
275 .file_name()
276 .and_then(|n| n.to_str())
277 .unwrap_or(path);
278
279 let is_dotfile = filename.starts_with('.')
281 || path.contains("/.")
282 || path.starts_with('.')
283 || Self::is_in_dotfile_directory(path);
284
285 if !is_dotfile {
286 return false;
287 }
288
289 for pattern in DEFAULT_PROTECTED_DOTFILES {
291 if Self::matches_pattern(path, pattern) || Self::matches_pattern(filename, pattern) {
292 return true;
293 }
294 }
295
296 for pattern in &self.additional_protected_patterns {
298 if Self::matches_pattern(path, pattern) || Self::matches_pattern(filename, pattern) {
299 return true;
300 }
301 }
302
303 filename.starts_with('.') || Self::is_in_dotfile_directory(path)
305 }
306
307 fn is_in_dotfile_directory(path: &str) -> bool {
309 let components: Vec<&str> = path.split('/').collect();
310 for component in &components {
311 if component.starts_with('.')
312 && !component.is_empty()
313 && *component != "."
314 && *component != ".."
315 {
316 return true;
317 }
318 }
319 false
320 }
321
322 pub fn is_whitelisted(&self, path: &str) -> bool {
324 let filename = std::path::Path::new(path)
325 .file_name()
326 .and_then(|n| n.to_str())
327 .unwrap_or(path);
328
329 self.whitelist.contains(path) || self.whitelist.contains(filename)
330 }
331
332 fn matches_pattern(path: &str, pattern: &str) -> bool {
334 if pattern.contains('*') {
335 if let Some(prefix) = pattern.strip_suffix("/*") {
337 path.starts_with(prefix)
338 || path.contains(&format!("/{}/", prefix.trim_start_matches('.')))
339 } else if pattern.ends_with(".*") {
340 let prefix = &pattern[..pattern.len() - 1];
341 path.starts_with(prefix)
342 } else {
343 let parts: Vec<&str> = pattern.split('*').collect();
345 if parts.len() == 2 {
346 path.starts_with(parts[0]) && path.ends_with(parts[1])
347 } else {
348 path == pattern
349 }
350 }
351 } else {
352 path == pattern || path.ends_with(&format!("/{}", pattern))
353 }
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_default_protection() {
363 let config = DotfileProtectionConfig::default();
364
365 assert!(config.is_protected(".gitignore"));
367 assert!(config.is_protected(".env"));
368 assert!(config.is_protected(".env.local"));
369 assert!(config.is_protected(".bashrc"));
370 assert!(config.is_protected(".ssh/config"));
371 assert!(config.is_protected("/home/user/.npmrc"));
372
373 assert!(!config.is_protected("README.md"));
375 assert!(!config.is_protected("src/main.rs"));
376 }
377
378 #[test]
379 fn test_whitelist() {
380 let mut config = DotfileProtectionConfig::default();
381 config.whitelist.insert(".gitignore".into());
382
383 assert!(config.is_whitelisted(".gitignore"));
384 assert!(!config.is_whitelisted(".env"));
385 }
386
387 #[test]
388 fn test_disabled_protection() {
389 let mut config = DotfileProtectionConfig::default();
390 config.enabled = false;
391
392 assert!(!config.is_protected(".gitignore"));
393 assert!(!config.is_protected(".env"));
394 }
395
396 #[test]
397 fn test_pattern_matching() {
398 assert!(DotfileProtectionConfig::matches_pattern(
399 ".env.local",
400 ".env.*"
401 ));
402 assert!(DotfileProtectionConfig::matches_pattern(
403 ".env.production",
404 ".env.*"
405 ));
406 assert!(DotfileProtectionConfig::matches_pattern(
407 ".vscode/settings.json",
408 ".vscode/*"
409 ));
410 }
411}