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]
293 pub fn is_design_doc(&self, rel: &str) -> bool {
294 self.write_class(rel) == Some(Class::Intent)
295 && !self.judge.is_match(rel)
296 && !self.context.is_match(rel)
297 && !self.is_manifest(rel)
298 && !rel.split('/').any(|s| s.starts_with('.'))
299 }
300
301 #[must_use]
303 pub fn is_manifest(&self, rel: &str) -> bool {
304 self.manifests.is_match(rel)
305 }
306}
307
308pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
310 let rel = normalise(rel)?;
311 let full = root.join(&rel);
312 let canonical =
313 fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
314 if canonical.starts_with(root) {
315 Ok(canonical)
316 } else {
317 Err(Refused(format!("`{rel}` resolves outside the workspace")))
318 }
319}
320
321pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
324 let rel = normalise(rel)?;
325 let full = root.join(&rel);
326 if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
327 return Err(Refused(format!(
328 "`{rel}` is a symlink; not written through"
329 )));
330 }
331 let mut ancestor = full.parent();
332 while let Some(a) = ancestor {
333 if a.exists() {
334 let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
335 if !canonical.starts_with(root) {
336 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
337 }
338 break;
339 }
340 ancestor = a.parent();
341 }
342 Ok(full)
343}
344
345pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
354 let rel = normalise(rel)?;
355 let parts: Vec<&str> = rel.split('/').collect();
356 let mut at = root.to_path_buf();
357 for (i, part) in parts.iter().enumerate() {
358 let next = at.join(part);
359 let Ok(meta) = fs::symlink_metadata(&next) else {
360 for rest in &parts[i..] {
362 at.push(rest);
363 }
364 break;
365 };
366 if i + 1 == parts.len() {
367 if meta.file_type().is_symlink() {
368 return Err(Refused(format!(
369 "`{rel}` is a symlink; not written through"
370 )));
371 }
372 let canonical =
374 fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
375 if !canonical.starts_with(root) {
376 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
377 }
378 at = canonical;
379 break;
380 }
381 let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
382 if !canonical.starts_with(root) {
383 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
384 }
385 at = canonical;
386 }
387 let inside = at
388 .strip_prefix(root)
389 .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
390 let out: Vec<String> = inside
391 .components()
392 .map(|c| c.as_os_str().to_string_lossy().into_owned())
393 .collect();
394 if out.is_empty() {
395 return Err(Refused("empty path".to_owned()));
396 }
397 Ok(out.join("/"))
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn normalises_and_refuses_escapes() {
406 assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
407 assert_eq!(normalise("src/").unwrap(), "src");
408 assert!(normalise("../x").is_err());
409 assert!(normalise("/etc/passwd").is_err());
410 assert!(normalise("a/../../b").is_err());
411 assert!(normalise("").is_err());
412 }
413
414 #[test]
415 fn classifies_the_protected_set() {
416 let p = Protected::default();
417 for (path, class) in [
418 ("specs/001-x/tasks.md", Class::Intent),
419 (".specify/memory/constitution.md", Class::Intent),
420 ("speccoder.toml", Class::Judge),
421 (".github/workflows/ci.yml", Class::Judge),
422 (".config/nextest.toml", Class::Judge),
423 ("web/jest.config.ts", Class::Judge),
424 (".git/config", Class::Git),
425 (".env", Class::Secret),
426 ("deploy/.env.production", Class::Secret),
427 ("certs/server.key", Class::Secret),
428 ] {
429 assert_eq!(p.write_class(path), Some(class), "{path}");
430 }
431 for agent in [
432 ".claude/settings.json",
433 ".claude/settings.local.json",
434 ".claude/skills/x/SKILL.md",
435 ".agents/skills/y/SKILL.md",
436 ".codex/config.toml",
437 ".kiro/hooks/a.json",
438 ".mcp.json",
439 "web/.mcp.json",
440 "opencode.json",
441 ] {
442 assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
443 }
444 for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
445 assert_eq!(p.write_class(context), None, "{context}");
446 assert!(p.is_context(context), "{context}");
447 }
448 assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
449 for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
450 assert_eq!(p.write_class(free), None, "{free}");
451 }
452 assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
453 assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
454 for t in [
455 "tests/api.rs",
456 "crates/x/tests/it.rs",
457 "app/test_login.py",
458 "web/a.test.ts",
459 "web/__tests__/b.js",
460 "pkg/x_test.go",
461 ] {
462 assert!(is_test_path(t), "{t}");
463 }
464 for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
465 assert!(!is_test_path(f), "{f}");
466 }
467 }
468
469 #[test]
472 fn letter_case_does_not_leave_the_protected_set() {
473 let p = Protected::default();
474 for (path, class) in [
475 ("Speccoder.toml", Class::Judge),
476 ("SPECCODER.TOML", Class::Judge),
477 ("makefile", Class::Judge),
478 ("JUSTFILE", Class::Judge),
479 (".CURSOR/rules.json", Class::Judge),
480 (".CLAUDE/settings.json", Class::Judge),
481 (".Github/workflows/ci.yml", Class::Judge),
482 ("Specs/001-x/tasks.md", Class::Intent),
483 (".SPECIFY/memory/constitution.md", Class::Intent),
484 (".GIT/config", Class::Git),
485 ("sub/.Git", Class::Git),
486 (".ENV", Class::Secret),
487 ("certs/server.KEY", Class::Secret),
488 ] {
489 assert_eq!(p.write_class(path), Some(class), "{path}");
490 }
491 assert!(p.is_review("specs/001-x/Review.JSON"));
492 assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
493 assert!(p.is_manifest("crates/x/cargo.TOML"));
494 assert!(is_test_path("Tests/api.rs"));
495 }
496
497 #[test]
501 fn an_existing_file_is_named_as_stored() {
502 let dir = tempfile::tempdir().unwrap();
503 let root = fs::canonicalize(dir.path()).unwrap();
504 fs::create_dir_all(root.join("src")).unwrap();
505 fs::write(root.join("speccoder.toml"), "").unwrap();
506 fs::write(root.join("src/lib.rs"), "").unwrap();
507 let insensitive = root.join("SPECCODER.toml").exists();
508 let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
509 let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
510 if insensitive {
511 assert_eq!(got, "speccoder.toml");
512 assert_eq!(lib, "src/lib.rs");
513 } else {
514 assert_eq!(got, "Speccoder.toml");
515 assert_eq!(lib, "SRC/Lib.rs");
516 }
517 }
518
519 #[test]
520 fn a_write_reaches_its_canonical_file() {
521 let dir = tempfile::tempdir().unwrap();
522 let root = fs::canonicalize(dir.path()).unwrap();
523 fs::create_dir_all(root.join("src/b")).unwrap();
524 fs::create_dir_all(root.join("src/a")).unwrap();
525 assert_eq!(
526 canonical_for_write(&root, "./src/a/x.rs").unwrap(),
527 "src/a/x.rs"
528 );
529 assert_eq!(
531 canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
532 "src/new/deep/x.rs"
533 );
534 fs::remove_dir(root.join("src/a")).unwrap();
536 std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
537 assert_eq!(
538 canonical_for_write(&root, "src/a/x.rs").unwrap(),
539 "src/b/x.rs"
540 );
541 let outside = tempfile::tempdir().unwrap();
543 std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
544 assert!(canonical_for_write(&root, "out/x.rs").is_err());
545 fs::write(root.join("src/b/real.rs"), "").unwrap();
546 std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
547 assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
548 assert!(canonical_for_write(&root, "../x").is_err());
549 }
550}