1use crate::format::Rules;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8use globset::{Glob, 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 {
38 let mut b = GlobSetBuilder::new();
39 for p in patterns {
40 if let Ok(g) = Glob::new(p) {
41 b.add(g);
42 }
43 }
44 b.build().unwrap_or_else(|_| GlobSet::empty())
45}
46
47const SECRETS: &[&str] = &[
49 ".env",
50 "**/.env",
51 ".env.*",
52 "**/.env.*",
53 "**/*.pem",
54 "**/*.key",
55 "**/id_rsa*",
56 "**/id_ed25519*",
57 ".ssh/**",
58 "**/.ssh/**",
59 "**/.netrc",
60 "**/.npmrc",
61 "**/.pypirc",
62];
63
64const JUDGE: &[&str] = &[
66 "speccoder.toml",
67 "**/speccoder.toml",
68 ".github/**",
69 ".gitlab-ci.yml",
70 ".cargo/config.toml",
71 ".cargo/config",
72 "nextest.toml",
73 ".config/nextest.toml",
74 "pytest.ini",
75 "tox.ini",
76 "**/jest.config.*",
77 "**/vitest.config.*",
78 "deny.toml",
79 "justfile",
80 "Makefile",
81 ".claude/**",
86 ".agents/**",
87 ".codex/**",
88 ".gemini/**",
89 ".opencode/**",
90 ".cursor/**",
91 ".kiro/**",
92 ".windsurf/**",
93 ".roo/**",
94 ".qwen/**",
95 ".kilocode/**",
96 ".augment/**",
97 ".amazonq/**",
98 ".codebuddy/**",
99 ".qoder/**",
100 ".mcp.json",
101 "**/.mcp.json",
102 "opencode.json",
103 "opencode.jsonc",
104];
105
106const CONTEXT: &[&str] = &[
109 "AGENTS.md",
110 "**/AGENTS.md",
111 "CLAUDE.md",
112 "**/CLAUDE.md",
113 "GEMINI.md",
114 "**/GEMINI.md",
115];
116
117const MANIFESTS: &[&str] = &[
119 "**/Cargo.toml",
120 "**/package.json",
121 "**/pyproject.toml",
122 "**/requirements*.txt",
123 "**/go.mod",
124 "**/Gemfile",
125 "**/pom.xml",
126 "**/build.gradle",
127 "**/build.gradle.kts",
128];
129
130const TESTS: &[&str] = &[
132 "tests/**",
133 "**/tests/**",
134 "test/**",
135 "**/test/**",
136 "**/__tests__/**",
137 "**/*_test.*",
138 "**/test_*.py",
139 "**/*.test.*",
140 "**/*.spec.*",
141 "**/*Test.java",
142 "**/*_spec.rb",
143];
144
145#[must_use]
147pub fn is_test_path(rel: &str) -> bool {
148 static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
149 SET.get_or_init(|| set(TESTS)).is_match(rel)
150}
151
152pub const REVIEW_FILE: &str = "review.json";
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum Class {
160 Intent,
162 Judge,
164 Git,
166 Secret,
168}
169
170impl Class {
171 #[must_use]
174 pub fn reason(self) -> &'static str {
175 self.reason_with(crate::format::reference().rules())
176 }
177
178 fn reason_with(self, rules: &Rules) -> &'static str {
179 match self {
180 Self::Intent => rules.intent_reason,
181 Self::Judge => {
182 "it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
183 }
184 Self::Git => "it is git's own data",
185 Self::Secret => "it looks like a credential",
186 }
187 }
188}
189
190#[derive(Debug, Clone)]
192pub struct Protected {
193 rules: &'static Rules,
194 secrets: GlobSet,
195 intent: GlobSet,
196 judge: GlobSet,
197 context: GlobSet,
198 manifests: GlobSet,
199}
200
201impl Default for Protected {
202 fn default() -> Self {
203 Self::new(crate::format::reference().rules())
204 }
205}
206
207impl Protected {
208 #[must_use]
210 pub fn new(rules: &'static Rules) -> Self {
211 Self {
212 rules,
213 secrets: set(SECRETS),
214 intent: set(rules.intent),
215 judge: set(JUDGE),
216 context: set(CONTEXT),
217 manifests: set(MANIFESTS),
218 }
219 }
220
221 #[must_use]
223 pub fn rules(&self) -> &'static Rules {
224 self.rules
225 }
226
227 #[must_use]
229 pub fn reason(&self, class: Class) -> &'static str {
230 class.reason_with(self.rules)
231 }
232
233 #[must_use]
236 pub fn is_review(&self, rel: &str) -> bool {
237 self.intent.is_match(rel) && rel.rsplit('/').next() == Some(REVIEW_FILE)
238 }
239
240 #[must_use]
242 pub fn is_secret(&self, rel: &str) -> bool {
243 self.secrets.is_match(rel)
244 }
245
246 #[must_use]
248 pub fn write_class(&self, rel: &str) -> Option<Class> {
249 if rel == ".git" || rel.starts_with(".git/") || rel.contains("/.git/") {
250 Some(Class::Git)
251 } else if self.is_secret(rel) {
252 Some(Class::Secret)
253 } else if self.intent.is_match(rel) {
254 Some(Class::Intent)
255 } else if self.judge.is_match(rel) {
256 Some(Class::Judge)
257 } else {
258 None
259 }
260 }
261
262 #[must_use]
265 pub fn is_context(&self, rel: &str) -> bool {
266 self.write_class(rel).is_none() && self.context.is_match(rel)
267 }
268
269 #[must_use]
272 pub fn is_judged(&self, rel: &str) -> bool {
273 matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
274 }
275
276 #[must_use]
278 pub fn is_manifest(&self, rel: &str) -> bool {
279 self.manifests.is_match(rel)
280 }
281}
282
283pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
285 let rel = normalise(rel)?;
286 let full = root.join(&rel);
287 let canonical =
288 fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
289 if canonical.starts_with(root) {
290 Ok(canonical)
291 } else {
292 Err(Refused(format!("`{rel}` resolves outside the workspace")))
293 }
294}
295
296pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
299 let rel = normalise(rel)?;
300 let full = root.join(&rel);
301 if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
302 return Err(Refused(format!(
303 "`{rel}` is a symlink; not written through"
304 )));
305 }
306 let mut ancestor = full.parent();
307 while let Some(a) = ancestor {
308 if a.exists() {
309 let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
310 if !canonical.starts_with(root) {
311 return Err(Refused(format!("`{rel}` resolves outside the workspace")));
312 }
313 break;
314 }
315 ancestor = a.parent();
316 }
317 Ok(full)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn normalises_and_refuses_escapes() {
326 assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
327 assert_eq!(normalise("src/").unwrap(), "src");
328 assert!(normalise("../x").is_err());
329 assert!(normalise("/etc/passwd").is_err());
330 assert!(normalise("a/../../b").is_err());
331 assert!(normalise("").is_err());
332 }
333
334 #[test]
335 fn classifies_the_protected_set() {
336 let p = Protected::default();
337 for (path, class) in [
338 ("specs/001-x/tasks.md", Class::Intent),
339 (".specify/memory/constitution.md", Class::Intent),
340 ("speccoder.toml", Class::Judge),
341 (".github/workflows/ci.yml", Class::Judge),
342 (".config/nextest.toml", Class::Judge),
343 ("web/jest.config.ts", Class::Judge),
344 (".git/config", Class::Git),
345 (".env", Class::Secret),
346 ("deploy/.env.production", Class::Secret),
347 ("certs/server.key", Class::Secret),
348 ] {
349 assert_eq!(p.write_class(path), Some(class), "{path}");
350 }
351 for agent in [
352 ".claude/settings.json",
353 ".claude/settings.local.json",
354 ".claude/skills/x/SKILL.md",
355 ".agents/skills/y/SKILL.md",
356 ".codex/config.toml",
357 ".kiro/hooks/a.json",
358 ".mcp.json",
359 "web/.mcp.json",
360 "opencode.json",
361 ] {
362 assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
363 }
364 for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
365 assert_eq!(p.write_class(context), None, "{context}");
366 assert!(p.is_context(context), "{context}");
367 }
368 assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
369 for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
370 assert_eq!(p.write_class(free), None, "{free}");
371 }
372 assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
373 assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
374 for t in [
375 "tests/api.rs",
376 "crates/x/tests/it.rs",
377 "app/test_login.py",
378 "web/a.test.ts",
379 "web/__tests__/b.js",
380 "pkg/x_test.go",
381 ] {
382 assert!(is_test_path(t), "{t}");
383 }
384 for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
385 assert!(!is_test_path(f), "{f}");
386 }
387 }
388}