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 ".speccoder/extensions/**",
111 "**/.speccoder/extensions/**",
112];
113
114const CONTEXT: &[&str] = &[
117 "AGENTS.md",
118 "**/AGENTS.md",
119 "CLAUDE.md",
120 "**/CLAUDE.md",
121 "GEMINI.md",
122 "**/GEMINI.md",
123];
124
125const MANIFESTS: &[&str] = &[
127 "**/Cargo.toml",
128 "**/package.json",
129 "**/pyproject.toml",
130 "**/requirements*.txt",
131 "**/go.mod",
132 "**/Gemfile",
133 "**/pom.xml",
134 "**/build.gradle",
135 "**/build.gradle.kts",
136];
137
138const TESTS: &[&str] = &[
140 "tests/**",
141 "**/tests/**",
142 "test/**",
143 "**/test/**",
144 "**/__tests__/**",
145 "**/*_test.*",
146 "**/test_*.py",
147 "**/*.test.*",
148 "**/*.spec.*",
149 "**/*Test.java",
150 "**/*_spec.rb",
151];
152
153#[must_use]
155pub fn is_test_path(rel: &str) -> bool {
156 static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
157 SET.get_or_init(|| set(TESTS)).is_match(rel)
158}
159
160pub const REVIEW_FILE: &str = "review.json";
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum Class {
168 Intent,
170 Judge,
172 Git,
174 Secret,
176}
177
178impl Class {
179 #[must_use]
182 pub fn reason(self) -> &'static str {
183 self.reason_with(crate::format::reference().rules())
184 }
185
186 fn reason_with(self, rules: &Rules) -> &'static str {
187 match self {
188 Self::Intent => rules.intent_reason,
189 Self::Judge => {
190 "it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
191 }
192 Self::Git => "it is git's own data",
193 Self::Secret => "it looks like a credential",
194 }
195 }
196}
197
198#[derive(Debug, Clone)]
200pub struct Protected {
201 rules: &'static Rules,
202 secrets: GlobSet,
203 intent: GlobSet,
204 judge: GlobSet,
205 context: GlobSet,
206 manifests: GlobSet,
207 hook_files: Vec<String>,
209}
210
211impl Default for Protected {
212 fn default() -> Self {
213 Self::new(crate::format::reference().rules())
214 }
215}
216
217impl Protected {
218 #[must_use]
220 pub fn new(rules: &'static Rules) -> Self {
221 Self {
222 rules,
223 secrets: set(SECRETS),
224 intent: set(rules.intent),
225 judge: set(JUDGE),
226 context: set(CONTEXT),
227 manifests: set(MANIFESTS),
228 hook_files: Vec::new(),
229 }
230 }
231
232 #[must_use]
234 pub fn with_hook_files(mut self, files: Vec<String>) -> Self {
235 self.hook_files = files;
236 self
237 }
238
239 #[must_use]
241 pub fn rules(&self) -> &'static Rules {
242 self.rules
243 }
244
245 #[must_use]
247 pub fn reason(&self, class: Class) -> &'static str {
248 class.reason_with(self.rules)
249 }
250
251 #[must_use]
254 pub fn is_review(&self, rel: &str) -> bool {
255 self.intent.is_match(rel)
256 && rel
257 .rsplit('/')
258 .next()
259 .is_some_and(|f| f.eq_ignore_ascii_case(REVIEW_FILE))
260 }
261
262 #[must_use]
264 pub fn is_secret(&self, rel: &str) -> bool {
265 self.secrets.is_match(rel)
266 }
267
268 #[must_use]
270 pub fn write_class(&self, rel: &str) -> Option<Class> {
271 let folded = rel.to_lowercase();
272 if folded == ".git"
273 || folded.starts_with(".git/")
274 || folded.contains("/.git/")
275 || folded.ends_with("/.git")
276 {
277 Some(Class::Git)
278 } else if self.is_secret(rel) {
279 Some(Class::Secret)
280 } else if self.intent.is_match(rel) {
281 Some(Class::Intent)
282 } else if self.judge.is_match(rel) || self.hook_files.iter().any(|f| f == rel) {
283 Some(Class::Judge)
284 } else {
285 None
286 }
287 }
288
289 #[must_use]
292 pub fn is_context(&self, rel: &str) -> bool {
293 self.write_class(rel).is_none() && self.context.is_match(rel)
294 }
295
296 #[must_use]
299 pub fn is_judged(&self, rel: &str) -> bool {
300 matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
301 }
302
303 #[must_use]
307 pub fn is_design_doc(&self, rel: &str) -> bool {
308 self.write_class(rel) == Some(Class::Intent)
309 && !self.judge.is_match(rel)
310 && !self.context.is_match(rel)
311 && !self.is_manifest(rel)
312 && !rel.split('/').any(|s| s.starts_with('.'))
313 }
314
315 #[must_use]
317 pub fn is_manifest(&self, rel: &str) -> bool {
318 self.manifests.is_match(rel)
319 }
320}
321
322pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
324 let rel = normalise(rel)?;
325 let full = root.join(&rel);
326 let canonical =
327 fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
328 if canonical.starts_with(root) {
329 Ok(canonical)
330 } else {
331 Err(Refused(format!("`{rel}` resolves outside the workspace")))
332 }
333}
334
335pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
338 let rel = normalise(rel)?;
339 let full = root.join(&rel);
340 if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
341 return Err(Refused(format!(
342 "`{rel}` is a symlink; not written through"
343 )));
344 }
345 let mut ancestor = full.parent();
346 while let Some(a) = ancestor {
347 if a.exists() {
348 let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
349 if !canonical.starts_with(root) {
350 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
351 }
352 break;
353 }
354 ancestor = a.parent();
355 }
356 Ok(full)
357}
358
359pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
368 let rel = normalise(rel)?;
369 let parts: Vec<&str> = rel.split('/').collect();
370 let mut at = root.to_path_buf();
371 for (i, part) in parts.iter().enumerate() {
372 let next = at.join(part);
373 let Ok(meta) = fs::symlink_metadata(&next) else {
374 for rest in &parts[i..] {
376 at.push(rest);
377 }
378 break;
379 };
380 if i + 1 == parts.len() {
381 if meta.file_type().is_symlink() {
382 return Err(Refused(format!(
383 "`{rel}` is a symlink; not written through"
384 )));
385 }
386 let canonical =
388 fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
389 if !canonical.starts_with(root) {
390 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
391 }
392 at = canonical;
393 break;
394 }
395 let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
396 if !canonical.starts_with(root) {
397 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
398 }
399 at = canonical;
400 }
401 let inside = at
402 .strip_prefix(root)
403 .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
404 let out: Vec<String> = inside
405 .components()
406 .map(|c| c.as_os_str().to_string_lossy().into_owned())
407 .collect();
408 if out.is_empty() {
409 return Err(Refused("empty path".to_owned()));
410 }
411 Ok(out.join("/"))
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn normalises_and_refuses_escapes() {
420 assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
421 assert_eq!(normalise("src/").unwrap(), "src");
422 assert!(normalise("../x").is_err());
423 assert!(normalise("/etc/passwd").is_err());
424 assert!(normalise("a/../../b").is_err());
425 assert!(normalise("").is_err());
426 }
427
428 #[test]
429 fn a_file_a_hook_runs_is_a_judge_file() {
430 let p = Protected::default().with_hook_files(vec!["scripts/licence.sh".into()]);
431 assert_eq!(p.write_class("scripts/licence.sh"), Some(Class::Judge));
432 assert_eq!(p.write_class("scripts/other.sh"), None);
433 assert!(p.is_judged("scripts/licence.sh"));
434 }
435
436 #[test]
437 fn classifies_the_protected_set() {
438 let p = Protected::default();
439 for (path, class) in [
440 ("specs/001-x/tasks.md", Class::Intent),
441 (".specify/memory/constitution.md", Class::Intent),
442 ("speccoder.toml", Class::Judge),
443 (
444 ".speccoder/extensions/kitchen/scripts/check.sh",
445 Class::Judge,
446 ),
447 (
448 "app/.specify/extensions/threatspec/extension.yml",
449 Class::Intent,
450 ),
451 (".github/workflows/ci.yml", Class::Judge),
452 (".config/nextest.toml", Class::Judge),
453 ("web/jest.config.ts", Class::Judge),
454 (".git/config", Class::Git),
455 (".env", Class::Secret),
456 ("deploy/.env.production", Class::Secret),
457 ("certs/server.key", Class::Secret),
458 ] {
459 assert_eq!(p.write_class(path), Some(class), "{path}");
460 }
461 for agent in [
462 ".claude/settings.json",
463 ".claude/settings.local.json",
464 ".claude/skills/x/SKILL.md",
465 ".agents/skills/y/SKILL.md",
466 ".codex/config.toml",
467 ".kiro/hooks/a.json",
468 ".mcp.json",
469 "web/.mcp.json",
470 "opencode.json",
471 ] {
472 assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
473 }
474 for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
475 assert_eq!(p.write_class(context), None, "{context}");
476 assert!(p.is_context(context), "{context}");
477 }
478 assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
479 for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
480 assert_eq!(p.write_class(free), None, "{free}");
481 }
482 assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
483 assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
484 for t in [
485 "tests/api.rs",
486 "crates/x/tests/it.rs",
487 "app/test_login.py",
488 "web/a.test.ts",
489 "web/__tests__/b.js",
490 "pkg/x_test.go",
491 ] {
492 assert!(is_test_path(t), "{t}");
493 }
494 for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
495 assert!(!is_test_path(f), "{f}");
496 }
497 }
498
499 #[test]
502 fn letter_case_does_not_leave_the_protected_set() {
503 let p = Protected::default();
504 for (path, class) in [
505 ("Speccoder.toml", Class::Judge),
506 ("SPECCODER.TOML", Class::Judge),
507 ("makefile", Class::Judge),
508 ("JUSTFILE", Class::Judge),
509 (".CURSOR/rules.json", Class::Judge),
510 (".CLAUDE/settings.json", Class::Judge),
511 (".Github/workflows/ci.yml", Class::Judge),
512 ("Specs/001-x/tasks.md", Class::Intent),
513 (".SPECIFY/memory/constitution.md", Class::Intent),
514 (".GIT/config", Class::Git),
515 ("sub/.Git", Class::Git),
516 (".ENV", Class::Secret),
517 ("certs/server.KEY", Class::Secret),
518 ] {
519 assert_eq!(p.write_class(path), Some(class), "{path}");
520 }
521 assert!(p.is_review("specs/001-x/Review.JSON"));
522 assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
523 assert!(p.is_manifest("crates/x/cargo.TOML"));
524 assert!(is_test_path("Tests/api.rs"));
525 }
526
527 #[test]
531 fn an_existing_file_is_named_as_stored() {
532 let dir = tempfile::tempdir().unwrap();
533 let root = fs::canonicalize(dir.path()).unwrap();
534 fs::create_dir_all(root.join("src")).unwrap();
535 fs::write(root.join("speccoder.toml"), "").unwrap();
536 fs::write(root.join("src/lib.rs"), "").unwrap();
537 let insensitive = root.join("SPECCODER.toml").exists();
538 let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
539 let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
540 if insensitive {
541 assert_eq!(got, "speccoder.toml");
542 assert_eq!(lib, "src/lib.rs");
543 } else {
544 assert_eq!(got, "Speccoder.toml");
545 assert_eq!(lib, "SRC/Lib.rs");
546 }
547 }
548
549 #[test]
550 fn a_write_reaches_its_canonical_file() {
551 let dir = tempfile::tempdir().unwrap();
552 let root = fs::canonicalize(dir.path()).unwrap();
553 fs::create_dir_all(root.join("src/b")).unwrap();
554 fs::create_dir_all(root.join("src/a")).unwrap();
555 assert_eq!(
556 canonical_for_write(&root, "./src/a/x.rs").unwrap(),
557 "src/a/x.rs"
558 );
559 assert_eq!(
561 canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
562 "src/new/deep/x.rs"
563 );
564 fs::remove_dir(root.join("src/a")).unwrap();
566 std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
567 assert_eq!(
568 canonical_for_write(&root, "src/a/x.rs").unwrap(),
569 "src/b/x.rs"
570 );
571 let outside = tempfile::tempdir().unwrap();
573 std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
574 assert!(canonical_for_write(&root, "out/x.rs").is_err());
575 fs::write(root.join("src/b/real.rs"), "").unwrap();
576 std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
577 assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
578 assert!(canonical_for_write(&root, "../x").is_err());
579 }
580}