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 pattern.ends_with("/*") {
337 let prefix = &pattern[..pattern.len() - 2];
338 path.starts_with(prefix)
339 || path.contains(&format!("/{}/", prefix.trim_start_matches('.')))
340 } else if pattern.ends_with(".*") {
341 let prefix = &pattern[..pattern.len() - 1];
342 path.starts_with(prefix)
343 } else {
344 let parts: Vec<&str> = pattern.split('*').collect();
346 if parts.len() == 2 {
347 path.starts_with(parts[0]) && path.ends_with(parts[1])
348 } else {
349 path == pattern
350 }
351 }
352 } else {
353 path == pattern || path.ends_with(&format!("/{}", pattern))
354 }
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn test_default_protection() {
364 let config = DotfileProtectionConfig::default();
365
366 assert!(config.is_protected(".gitignore"));
368 assert!(config.is_protected(".env"));
369 assert!(config.is_protected(".env.local"));
370 assert!(config.is_protected(".bashrc"));
371 assert!(config.is_protected(".ssh/config"));
372 assert!(config.is_protected("/home/user/.npmrc"));
373
374 assert!(!config.is_protected("README.md"));
376 assert!(!config.is_protected("src/main.rs"));
377 }
378
379 #[test]
380 fn test_whitelist() {
381 let mut config = DotfileProtectionConfig::default();
382 config.whitelist.insert(".gitignore".into());
383
384 assert!(config.is_whitelisted(".gitignore"));
385 assert!(!config.is_whitelisted(".env"));
386 }
387
388 #[test]
389 fn test_disabled_protection() {
390 let mut config = DotfileProtectionConfig::default();
391 config.enabled = false;
392
393 assert!(!config.is_protected(".gitignore"));
394 assert!(!config.is_protected(".env"));
395 }
396
397 #[test]
398 fn test_pattern_matching() {
399 assert!(DotfileProtectionConfig::matches_pattern(
400 ".env.local",
401 ".env.*"
402 ));
403 assert!(DotfileProtectionConfig::matches_pattern(
404 ".env.production",
405 ".env.*"
406 ));
407 assert!(DotfileProtectionConfig::matches_pattern(
408 ".vscode/settings.json",
409 ".vscode/*"
410 ));
411 }
412}