1use crate::format::Rules;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Refused(pub String);
13
14pub fn normalise(rel: &str) -> Result<String, Refused> {
16 if rel.contains('\0') {
17 return Err(Refused("path contains NUL".to_owned()));
18 }
19 let mut parts = Vec::new();
20 for c in Path::new(rel).components() {
21 match c {
22 Component::Normal(p) => parts.push(p.to_string_lossy().into_owned()),
23 Component::CurDir => {}
24 _ => {
25 return Err(Refused(format!(
26 "`{rel}` must be relative to the workspace and must not use `..`"
27 )));
28 }
29 }
30 }
31 if parts.is_empty() {
32 return Err(Refused("empty path".to_owned()));
33 }
34 Ok(parts.join("/"))
35}
36
37fn set(patterns: &[&str]) -> GlobSet {
42 let mut b = GlobSetBuilder::new();
43 for p in patterns {
44 if let Ok(g) = GlobBuilder::new(p).case_insensitive(true).build() {
45 b.add(g);
46 }
47 }
48 b.build().unwrap_or_else(|_| GlobSet::empty())
49}
50
51const SECRETS: &[&str] = &[
53 ".env",
54 "**/.env",
55 ".env.*",
56 "**/.env.*",
57 "**/*.pem",
58 "**/*.key",
59 "**/id_rsa*",
60 "**/id_ed25519*",
61 ".ssh/**",
62 "**/.ssh/**",
63 "**/.netrc",
64 "**/.npmrc",
65 "**/.pypirc",
66];
67
68const JUDGE: &[&str] = &[
70 "speccoder.toml",
71 "**/speccoder.toml",
72 ".github/**",
73 ".gitlab-ci.yml",
74 ".cargo/config.toml",
75 ".cargo/config",
76 "nextest.toml",
77 ".config/nextest.toml",
78 "pytest.ini",
79 "tox.ini",
80 "**/jest.config.*",
81 "**/vitest.config.*",
82 "deny.toml",
83 "justfile",
84 "Makefile",
85 ".claude/**",
90 ".agents/**",
91 ".codex/**",
92 ".gemini/**",
93 ".opencode/**",
94 ".cursor/**",
95 ".kiro/**",
96 ".windsurf/**",
97 ".roo/**",
98 ".qwen/**",
99 ".kilocode/**",
100 ".augment/**",
101 ".amazonq/**",
102 ".codebuddy/**",
103 ".qoder/**",
104 ".mcp.json",
105 "**/.mcp.json",
106 "opencode.json",
107 "opencode.jsonc",
108];
109
110const CONTEXT: &[&str] = &[
113 "AGENTS.md",
114 "**/AGENTS.md",
115 "CLAUDE.md",
116 "**/CLAUDE.md",
117 "GEMINI.md",
118 "**/GEMINI.md",
119];
120
121const MANIFESTS: &[&str] = &[
123 "**/Cargo.toml",
124 "**/package.json",
125 "**/pyproject.toml",
126 "**/requirements*.txt",
127 "**/go.mod",
128 "**/Gemfile",
129 "**/pom.xml",
130 "**/build.gradle",
131 "**/build.gradle.kts",
132];
133
134const TESTS: &[&str] = &[
136 "tests/**",
137 "**/tests/**",
138 "test/**",
139 "**/test/**",
140 "**/__tests__/**",
141 "**/*_test.*",
142 "**/test_*.py",
143 "**/*.test.*",
144 "**/*.spec.*",
145 "**/*Test.java",
146 "**/*_spec.rb",
147];
148
149#[must_use]
151pub fn is_test_path(rel: &str) -> bool {
152 static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
153 SET.get_or_init(|| set(TESTS)).is_match(rel)
154}
155
156pub const REVIEW_FILE: &str = "review.json";
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum Class {
164 Intent,
166 Judge,
168 Git,
170 Secret,
172}
173
174impl Class {
175 #[must_use]
178 pub fn reason(self) -> &'static str {
179 self.reason_with(crate::format::reference().rules())
180 }
181
182 fn reason_with(self, rules: &Rules) -> &'static str {
183 match self {
184 Self::Intent => rules.intent_reason,
185 Self::Judge => {
186 "it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
187 }
188 Self::Git => "it is git's own data",
189 Self::Secret => "it looks like a credential",
190 }
191 }
192}
193
194#[derive(Debug, Clone)]
196pub struct Protected {
197 rules: &'static Rules,
198 secrets: GlobSet,
199 intent: GlobSet,
200 judge: GlobSet,
201 context: GlobSet,
202 manifests: GlobSet,
203 hook_files: Vec<String>,
205}
206
207impl Default for Protected {
208 fn default() -> Self {
209 Self::new(crate::format::reference().rules())
210 }
211}
212
213impl Protected {
214 #[must_use]
216 pub fn new(rules: &'static Rules) -> Self {
217 Self {
218 rules,
219 secrets: set(SECRETS),
220 intent: set(rules.intent),
221 judge: set(JUDGE),
222 context: set(CONTEXT),
223 manifests: set(MANIFESTS),
224 hook_files: Vec::new(),
225 }
226 }
227
228 #[must_use]
230 pub fn with_hook_files(mut self, files: Vec<String>) -> Self {
231 self.hook_files = files;
232 self
233 }
234
235 #[must_use]
237 pub fn rules(&self) -> &'static Rules {
238 self.rules
239 }
240
241 #[must_use]
243 pub fn reason(&self, class: Class) -> &'static str {
244 class.reason_with(self.rules)
245 }
246
247 #[must_use]
250 pub fn is_review(&self, rel: &str) -> bool {
251 self.intent.is_match(rel)
252 && rel
253 .rsplit('/')
254 .next()
255 .is_some_and(|f| f.eq_ignore_ascii_case(REVIEW_FILE))
256 }
257
258 #[must_use]
260 pub fn is_secret(&self, rel: &str) -> bool {
261 self.secrets.is_match(rel)
262 }
263
264 #[must_use]
266 pub fn write_class(&self, rel: &str) -> Option<Class> {
267 let folded = rel.to_lowercase();
268 if folded == ".git"
269 || folded.starts_with(".git/")
270 || folded.contains("/.git/")
271 || folded.ends_with("/.git")
272 {
273 Some(Class::Git)
274 } else if self.is_secret(rel) {
275 Some(Class::Secret)
276 } else if self.intent.is_match(rel) {
277 Some(Class::Intent)
278 } else if self.judge.is_match(rel) || self.hook_files.iter().any(|f| f == rel) {
279 Some(Class::Judge)
280 } else {
281 None
282 }
283 }
284
285 #[must_use]
288 pub fn is_context(&self, rel: &str) -> bool {
289 self.write_class(rel).is_none() && self.context.is_match(rel)
290 }
291
292 #[must_use]
295 pub fn is_judged(&self, rel: &str) -> bool {
296 matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
297 }
298
299 #[must_use]
303 pub fn is_design_doc(&self, rel: &str) -> bool {
304 self.write_class(rel) == Some(Class::Intent)
305 && !self.judge.is_match(rel)
306 && !self.context.is_match(rel)
307 && !self.is_manifest(rel)
308 && !rel.split('/').any(|s| s.starts_with('.'))
309 }
310
311 #[must_use]
313 pub fn is_manifest(&self, rel: &str) -> bool {
314 self.manifests.is_match(rel)
315 }
316}
317
318pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
320 let rel = normalise(rel)?;
321 let full = root.join(&rel);
322 let canonical =
323 fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
324 if canonical.starts_with(root) {
325 Ok(canonical)
326 } else {
327 Err(Refused(format!("`{rel}` resolves outside the workspace")))
328 }
329}
330
331pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
334 let rel = normalise(rel)?;
335 let full = root.join(&rel);
336 if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
337 return Err(Refused(format!(
338 "`{rel}` is a symlink; not written through"
339 )));
340 }
341 let mut ancestor = full.parent();
342 while let Some(a) = ancestor {
343 if a.exists() {
344 let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
345 if !canonical.starts_with(root) {
346 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
347 }
348 break;
349 }
350 ancestor = a.parent();
351 }
352 Ok(full)
353}
354
355pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
364 let rel = normalise(rel)?;
365 let parts: Vec<&str> = rel.split('/').collect();
366 let mut at = root.to_path_buf();
367 for (i, part) in parts.iter().enumerate() {
368 let next = at.join(part);
369 let Ok(meta) = fs::symlink_metadata(&next) else {
370 for rest in &parts[i..] {
372 at.push(rest);
373 }
374 break;
375 };
376 if i + 1 == parts.len() {
377 if meta.file_type().is_symlink() {
378 return Err(Refused(format!(
379 "`{rel}` is a symlink; not written through"
380 )));
381 }
382 let canonical =
384 fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
385 if !canonical.starts_with(root) {
386 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
387 }
388 at = canonical;
389 break;
390 }
391 let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
392 if !canonical.starts_with(root) {
393 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
394 }
395 at = canonical;
396 }
397 let inside = at
398 .strip_prefix(root)
399 .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
400 let out: Vec<String> = inside
401 .components()
402 .map(|c| c.as_os_str().to_string_lossy().into_owned())
403 .collect();
404 if out.is_empty() {
405 return Err(Refused("empty path".to_owned()));
406 }
407 Ok(out.join("/"))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn normalises_and_refuses_escapes() {
416 assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
417 assert_eq!(normalise("src/").unwrap(), "src");
418 assert!(normalise("../x").is_err());
419 assert!(normalise("/etc/passwd").is_err());
420 assert!(normalise("a/../../b").is_err());
421 assert!(normalise("").is_err());
422 }
423
424 #[test]
425 fn a_file_a_hook_runs_is_a_judge_file() {
426 let p = Protected::default().with_hook_files(vec!["scripts/licence.sh".into()]);
427 assert_eq!(p.write_class("scripts/licence.sh"), Some(Class::Judge));
428 assert_eq!(p.write_class("scripts/other.sh"), None);
429 assert!(p.is_judged("scripts/licence.sh"));
430 }
431
432 #[test]
433 fn classifies_the_protected_set() {
434 let p = Protected::default();
435 for (path, class) in [
436 ("specs/001-x/tasks.md", Class::Intent),
437 (".specify/memory/constitution.md", Class::Intent),
438 ("speccoder.toml", Class::Judge),
439 (".github/workflows/ci.yml", Class::Judge),
440 (".config/nextest.toml", Class::Judge),
441 ("web/jest.config.ts", Class::Judge),
442 (".git/config", Class::Git),
443 (".env", Class::Secret),
444 ("deploy/.env.production", Class::Secret),
445 ("certs/server.key", Class::Secret),
446 ] {
447 assert_eq!(p.write_class(path), Some(class), "{path}");
448 }
449 for agent in [
450 ".claude/settings.json",
451 ".claude/settings.local.json",
452 ".claude/skills/x/SKILL.md",
453 ".agents/skills/y/SKILL.md",
454 ".codex/config.toml",
455 ".kiro/hooks/a.json",
456 ".mcp.json",
457 "web/.mcp.json",
458 "opencode.json",
459 ] {
460 assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
461 }
462 for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
463 assert_eq!(p.write_class(context), None, "{context}");
464 assert!(p.is_context(context), "{context}");
465 }
466 assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
467 for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
468 assert_eq!(p.write_class(free), None, "{free}");
469 }
470 assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
471 assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
472 for t in [
473 "tests/api.rs",
474 "crates/x/tests/it.rs",
475 "app/test_login.py",
476 "web/a.test.ts",
477 "web/__tests__/b.js",
478 "pkg/x_test.go",
479 ] {
480 assert!(is_test_path(t), "{t}");
481 }
482 for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
483 assert!(!is_test_path(f), "{f}");
484 }
485 }
486
487 #[test]
490 fn letter_case_does_not_leave_the_protected_set() {
491 let p = Protected::default();
492 for (path, class) in [
493 ("Speccoder.toml", Class::Judge),
494 ("SPECCODER.TOML", Class::Judge),
495 ("makefile", Class::Judge),
496 ("JUSTFILE", Class::Judge),
497 (".CURSOR/rules.json", Class::Judge),
498 (".CLAUDE/settings.json", Class::Judge),
499 (".Github/workflows/ci.yml", Class::Judge),
500 ("Specs/001-x/tasks.md", Class::Intent),
501 (".SPECIFY/memory/constitution.md", Class::Intent),
502 (".GIT/config", Class::Git),
503 ("sub/.Git", Class::Git),
504 (".ENV", Class::Secret),
505 ("certs/server.KEY", Class::Secret),
506 ] {
507 assert_eq!(p.write_class(path), Some(class), "{path}");
508 }
509 assert!(p.is_review("specs/001-x/Review.JSON"));
510 assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
511 assert!(p.is_manifest("crates/x/cargo.TOML"));
512 assert!(is_test_path("Tests/api.rs"));
513 }
514
515 #[test]
519 fn an_existing_file_is_named_as_stored() {
520 let dir = tempfile::tempdir().unwrap();
521 let root = fs::canonicalize(dir.path()).unwrap();
522 fs::create_dir_all(root.join("src")).unwrap();
523 fs::write(root.join("speccoder.toml"), "").unwrap();
524 fs::write(root.join("src/lib.rs"), "").unwrap();
525 let insensitive = root.join("SPECCODER.toml").exists();
526 let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
527 let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
528 if insensitive {
529 assert_eq!(got, "speccoder.toml");
530 assert_eq!(lib, "src/lib.rs");
531 } else {
532 assert_eq!(got, "Speccoder.toml");
533 assert_eq!(lib, "SRC/Lib.rs");
534 }
535 }
536
537 #[test]
538 fn a_write_reaches_its_canonical_file() {
539 let dir = tempfile::tempdir().unwrap();
540 let root = fs::canonicalize(dir.path()).unwrap();
541 fs::create_dir_all(root.join("src/b")).unwrap();
542 fs::create_dir_all(root.join("src/a")).unwrap();
543 assert_eq!(
544 canonical_for_write(&root, "./src/a/x.rs").unwrap(),
545 "src/a/x.rs"
546 );
547 assert_eq!(
549 canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
550 "src/new/deep/x.rs"
551 );
552 fs::remove_dir(root.join("src/a")).unwrap();
554 std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
555 assert_eq!(
556 canonical_for_write(&root, "src/a/x.rs").unwrap(),
557 "src/b/x.rs"
558 );
559 let outside = tempfile::tempdir().unwrap();
561 std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
562 assert!(canonical_for_write(&root, "out/x.rs").is_err());
563 fs::write(root.join("src/b/real.rs"), "").unwrap();
564 std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
565 assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
566 assert!(canonical_for_write(&root, "../x").is_err());
567 }
568}