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}
204
205impl Default for Protected {
206 fn default() -> Self {
207 Self::new(crate::format::reference().rules())
208 }
209}
210
211impl Protected {
212 #[must_use]
214 pub fn new(rules: &'static Rules) -> Self {
215 Self {
216 rules,
217 secrets: set(SECRETS),
218 intent: set(rules.intent),
219 judge: set(JUDGE),
220 context: set(CONTEXT),
221 manifests: set(MANIFESTS),
222 }
223 }
224
225 #[must_use]
227 pub fn rules(&self) -> &'static Rules {
228 self.rules
229 }
230
231 #[must_use]
233 pub fn reason(&self, class: Class) -> &'static str {
234 class.reason_with(self.rules)
235 }
236
237 #[must_use]
240 pub fn is_review(&self, rel: &str) -> bool {
241 self.intent.is_match(rel)
242 && rel
243 .rsplit('/')
244 .next()
245 .is_some_and(|f| f.eq_ignore_ascii_case(REVIEW_FILE))
246 }
247
248 #[must_use]
250 pub fn is_secret(&self, rel: &str) -> bool {
251 self.secrets.is_match(rel)
252 }
253
254 #[must_use]
256 pub fn write_class(&self, rel: &str) -> Option<Class> {
257 let folded = rel.to_lowercase();
258 if folded == ".git"
259 || folded.starts_with(".git/")
260 || folded.contains("/.git/")
261 || folded.ends_with("/.git")
262 {
263 Some(Class::Git)
264 } else if self.is_secret(rel) {
265 Some(Class::Secret)
266 } else if self.intent.is_match(rel) {
267 Some(Class::Intent)
268 } else if self.judge.is_match(rel) {
269 Some(Class::Judge)
270 } else {
271 None
272 }
273 }
274
275 #[must_use]
278 pub fn is_context(&self, rel: &str) -> bool {
279 self.write_class(rel).is_none() && self.context.is_match(rel)
280 }
281
282 #[must_use]
285 pub fn is_judged(&self, rel: &str) -> bool {
286 matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
287 }
288
289 #[must_use]
291 pub fn is_manifest(&self, rel: &str) -> bool {
292 self.manifests.is_match(rel)
293 }
294}
295
296pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
298 let rel = normalise(rel)?;
299 let full = root.join(&rel);
300 let canonical =
301 fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
302 if canonical.starts_with(root) {
303 Ok(canonical)
304 } else {
305 Err(Refused(format!("`{rel}` resolves outside the workspace")))
306 }
307}
308
309pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
312 let rel = normalise(rel)?;
313 let full = root.join(&rel);
314 if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
315 return Err(Refused(format!(
316 "`{rel}` is a symlink; not written through"
317 )));
318 }
319 let mut ancestor = full.parent();
320 while let Some(a) = ancestor {
321 if a.exists() {
322 let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
323 if !canonical.starts_with(root) {
324 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
325 }
326 break;
327 }
328 ancestor = a.parent();
329 }
330 Ok(full)
331}
332
333pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
342 let rel = normalise(rel)?;
343 let parts: Vec<&str> = rel.split('/').collect();
344 let mut at = root.to_path_buf();
345 for (i, part) in parts.iter().enumerate() {
346 let next = at.join(part);
347 let Ok(meta) = fs::symlink_metadata(&next) else {
348 for rest in &parts[i..] {
350 at.push(rest);
351 }
352 break;
353 };
354 if i + 1 == parts.len() {
355 if meta.file_type().is_symlink() {
356 return Err(Refused(format!(
357 "`{rel}` is a symlink; not written through"
358 )));
359 }
360 let canonical =
362 fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
363 if !canonical.starts_with(root) {
364 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
365 }
366 at = canonical;
367 break;
368 }
369 let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
370 if !canonical.starts_with(root) {
371 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
372 }
373 at = canonical;
374 }
375 let inside = at
376 .strip_prefix(root)
377 .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
378 let out: Vec<String> = inside
379 .components()
380 .map(|c| c.as_os_str().to_string_lossy().into_owned())
381 .collect();
382 if out.is_empty() {
383 return Err(Refused("empty path".to_owned()));
384 }
385 Ok(out.join("/"))
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn normalises_and_refuses_escapes() {
394 assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
395 assert_eq!(normalise("src/").unwrap(), "src");
396 assert!(normalise("../x").is_err());
397 assert!(normalise("/etc/passwd").is_err());
398 assert!(normalise("a/../../b").is_err());
399 assert!(normalise("").is_err());
400 }
401
402 #[test]
403 fn classifies_the_protected_set() {
404 let p = Protected::default();
405 for (path, class) in [
406 ("specs/001-x/tasks.md", Class::Intent),
407 (".specify/memory/constitution.md", Class::Intent),
408 ("speccoder.toml", Class::Judge),
409 (".github/workflows/ci.yml", Class::Judge),
410 (".config/nextest.toml", Class::Judge),
411 ("web/jest.config.ts", Class::Judge),
412 (".git/config", Class::Git),
413 (".env", Class::Secret),
414 ("deploy/.env.production", Class::Secret),
415 ("certs/server.key", Class::Secret),
416 ] {
417 assert_eq!(p.write_class(path), Some(class), "{path}");
418 }
419 for agent in [
420 ".claude/settings.json",
421 ".claude/settings.local.json",
422 ".claude/skills/x/SKILL.md",
423 ".agents/skills/y/SKILL.md",
424 ".codex/config.toml",
425 ".kiro/hooks/a.json",
426 ".mcp.json",
427 "web/.mcp.json",
428 "opencode.json",
429 ] {
430 assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
431 }
432 for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
433 assert_eq!(p.write_class(context), None, "{context}");
434 assert!(p.is_context(context), "{context}");
435 }
436 assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
437 for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
438 assert_eq!(p.write_class(free), None, "{free}");
439 }
440 assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
441 assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
442 for t in [
443 "tests/api.rs",
444 "crates/x/tests/it.rs",
445 "app/test_login.py",
446 "web/a.test.ts",
447 "web/__tests__/b.js",
448 "pkg/x_test.go",
449 ] {
450 assert!(is_test_path(t), "{t}");
451 }
452 for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
453 assert!(!is_test_path(f), "{f}");
454 }
455 }
456
457 #[test]
460 fn letter_case_does_not_leave_the_protected_set() {
461 let p = Protected::default();
462 for (path, class) in [
463 ("Speccoder.toml", Class::Judge),
464 ("SPECCODER.TOML", Class::Judge),
465 ("makefile", Class::Judge),
466 ("JUSTFILE", Class::Judge),
467 (".CURSOR/rules.json", Class::Judge),
468 (".CLAUDE/settings.json", Class::Judge),
469 (".Github/workflows/ci.yml", Class::Judge),
470 ("Specs/001-x/tasks.md", Class::Intent),
471 (".SPECIFY/memory/constitution.md", Class::Intent),
472 (".GIT/config", Class::Git),
473 ("sub/.Git", Class::Git),
474 (".ENV", Class::Secret),
475 ("certs/server.KEY", Class::Secret),
476 ] {
477 assert_eq!(p.write_class(path), Some(class), "{path}");
478 }
479 assert!(p.is_review("specs/001-x/Review.JSON"));
480 assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
481 assert!(p.is_manifest("crates/x/cargo.TOML"));
482 assert!(is_test_path("Tests/api.rs"));
483 }
484
485 #[test]
489 fn an_existing_file_is_named_as_stored() {
490 let dir = tempfile::tempdir().unwrap();
491 let root = fs::canonicalize(dir.path()).unwrap();
492 fs::create_dir_all(root.join("src")).unwrap();
493 fs::write(root.join("speccoder.toml"), "").unwrap();
494 fs::write(root.join("src/lib.rs"), "").unwrap();
495 let insensitive = root.join("SPECCODER.toml").exists();
496 let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
497 let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
498 if insensitive {
499 assert_eq!(got, "speccoder.toml");
500 assert_eq!(lib, "src/lib.rs");
501 } else {
502 assert_eq!(got, "Speccoder.toml");
503 assert_eq!(lib, "SRC/Lib.rs");
504 }
505 }
506
507 #[test]
508 fn a_write_reaches_its_canonical_file() {
509 let dir = tempfile::tempdir().unwrap();
510 let root = fs::canonicalize(dir.path()).unwrap();
511 fs::create_dir_all(root.join("src/b")).unwrap();
512 fs::create_dir_all(root.join("src/a")).unwrap();
513 assert_eq!(
514 canonical_for_write(&root, "./src/a/x.rs").unwrap(),
515 "src/a/x.rs"
516 );
517 assert_eq!(
519 canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
520 "src/new/deep/x.rs"
521 );
522 fs::remove_dir(root.join("src/a")).unwrap();
524 std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
525 assert_eq!(
526 canonical_for_write(&root, "src/a/x.rs").unwrap(),
527 "src/b/x.rs"
528 );
529 let outside = tempfile::tempdir().unwrap();
531 std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
532 assert!(canonical_for_write(&root, "out/x.rs").is_err());
533 fs::write(root.join("src/b/real.rs"), "").unwrap();
534 std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
535 assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
536 assert!(canonical_for_write(&root, "../x").is_err());
537 }
538}