use crate::format::Rules;
use std::fs;
use std::path::{Component, Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused(pub String);
pub fn normalise(rel: &str) -> Result<String, Refused> {
if rel.contains('\0') {
return Err(Refused("path contains NUL".to_owned()));
}
let mut parts = Vec::new();
for c in Path::new(rel).components() {
match c {
Component::Normal(p) => parts.push(p.to_string_lossy().into_owned()),
Component::CurDir => {}
_ => {
return Err(Refused(format!(
"`{rel}` must be relative to the workspace and must not use `..`"
)));
}
}
}
if parts.is_empty() {
return Err(Refused("empty path".to_owned()));
}
Ok(parts.join("/"))
}
fn set(patterns: &[&str]) -> GlobSet {
let mut b = GlobSetBuilder::new();
for p in patterns {
if let Ok(g) = Glob::new(p) {
b.add(g);
}
}
b.build().unwrap_or_else(|_| GlobSet::empty())
}
const SECRETS: &[&str] = &[
".env",
"**/.env",
".env.*",
"**/.env.*",
"**/*.pem",
"**/*.key",
"**/id_rsa*",
"**/id_ed25519*",
".ssh/**",
"**/.ssh/**",
"**/.netrc",
"**/.npmrc",
"**/.pypirc",
];
const JUDGE: &[&str] = &[
"speccoder.toml",
"**/speccoder.toml",
".github/**",
".gitlab-ci.yml",
".cargo/config.toml",
".cargo/config",
"nextest.toml",
".config/nextest.toml",
"pytest.ini",
"tox.ini",
"**/jest.config.*",
"**/vitest.config.*",
"deny.toml",
"justfile",
"Makefile",
".claude/**",
".agents/**",
".codex/**",
".gemini/**",
".opencode/**",
".cursor/**",
".kiro/**",
".windsurf/**",
".roo/**",
".qwen/**",
".kilocode/**",
".augment/**",
".amazonq/**",
".codebuddy/**",
".qoder/**",
".mcp.json",
"**/.mcp.json",
"opencode.json",
"opencode.jsonc",
];
const CONTEXT: &[&str] = &[
"AGENTS.md",
"**/AGENTS.md",
"CLAUDE.md",
"**/CLAUDE.md",
"GEMINI.md",
"**/GEMINI.md",
];
const MANIFESTS: &[&str] = &[
"**/Cargo.toml",
"**/package.json",
"**/pyproject.toml",
"**/requirements*.txt",
"**/go.mod",
"**/Gemfile",
"**/pom.xml",
"**/build.gradle",
"**/build.gradle.kts",
];
const TESTS: &[&str] = &[
"tests/**",
"**/tests/**",
"test/**",
"**/test/**",
"**/__tests__/**",
"**/*_test.*",
"**/test_*.py",
"**/*.test.*",
"**/*.spec.*",
"**/*Test.java",
"**/*_spec.rb",
];
#[must_use]
pub fn is_test_path(rel: &str) -> bool {
static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
SET.get_or_init(|| set(TESTS)).is_match(rel)
}
pub const REVIEW_FILE: &str = "review.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
Intent,
Judge,
Git,
Secret,
}
impl Class {
#[must_use]
pub fn reason(self) -> &'static str {
self.reason_with(crate::format::reference().rules())
}
fn reason_with(self, rules: &Rules) -> &'static str {
match self {
Self::Intent => rules.intent_reason,
Self::Judge => {
"it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
}
Self::Git => "it is git's own data",
Self::Secret => "it looks like a credential",
}
}
}
#[derive(Debug, Clone)]
pub struct Protected {
rules: &'static Rules,
secrets: GlobSet,
intent: GlobSet,
judge: GlobSet,
context: GlobSet,
manifests: GlobSet,
}
impl Default for Protected {
fn default() -> Self {
Self::new(crate::format::reference().rules())
}
}
impl Protected {
#[must_use]
pub fn new(rules: &'static Rules) -> Self {
Self {
rules,
secrets: set(SECRETS),
intent: set(rules.intent),
judge: set(JUDGE),
context: set(CONTEXT),
manifests: set(MANIFESTS),
}
}
#[must_use]
pub fn rules(&self) -> &'static Rules {
self.rules
}
#[must_use]
pub fn reason(&self, class: Class) -> &'static str {
class.reason_with(self.rules)
}
#[must_use]
pub fn is_review(&self, rel: &str) -> bool {
self.intent.is_match(rel) && rel.rsplit('/').next() == Some(REVIEW_FILE)
}
#[must_use]
pub fn is_secret(&self, rel: &str) -> bool {
self.secrets.is_match(rel)
}
#[must_use]
pub fn write_class(&self, rel: &str) -> Option<Class> {
if rel == ".git" || rel.starts_with(".git/") || rel.contains("/.git/") {
Some(Class::Git)
} else if self.is_secret(rel) {
Some(Class::Secret)
} else if self.intent.is_match(rel) {
Some(Class::Intent)
} else if self.judge.is_match(rel) {
Some(Class::Judge)
} else {
None
}
}
#[must_use]
pub fn is_context(&self, rel: &str) -> bool {
self.write_class(rel).is_none() && self.context.is_match(rel)
}
#[must_use]
pub fn is_judged(&self, rel: &str) -> bool {
matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
}
#[must_use]
pub fn is_manifest(&self, rel: &str) -> bool {
self.manifests.is_match(rel)
}
}
pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
let rel = normalise(rel)?;
let full = root.join(&rel);
let canonical =
fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
if canonical.starts_with(root) {
Ok(canonical)
} else {
Err(Refused(format!("`{rel}` resolves outside the workspace")))
}
}
pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
let rel = normalise(rel)?;
let full = root.join(&rel);
if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(Refused(format!(
"`{rel}` is a symlink; not written through"
)));
}
let mut ancestor = full.parent();
while let Some(a) = ancestor {
if a.exists() {
let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
if !canonical.starts_with(root) {
return Err(Refused(format!("`{rel}` resolves outside the workspace")));
}
break;
}
ancestor = a.parent();
}
Ok(full)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalises_and_refuses_escapes() {
assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
assert_eq!(normalise("src/").unwrap(), "src");
assert!(normalise("../x").is_err());
assert!(normalise("/etc/passwd").is_err());
assert!(normalise("a/../../b").is_err());
assert!(normalise("").is_err());
}
#[test]
fn classifies_the_protected_set() {
let p = Protected::default();
for (path, class) in [
("specs/001-x/tasks.md", Class::Intent),
(".specify/memory/constitution.md", Class::Intent),
("speccoder.toml", Class::Judge),
(".github/workflows/ci.yml", Class::Judge),
(".config/nextest.toml", Class::Judge),
("web/jest.config.ts", Class::Judge),
(".git/config", Class::Git),
(".env", Class::Secret),
("deploy/.env.production", Class::Secret),
("certs/server.key", Class::Secret),
] {
assert_eq!(p.write_class(path), Some(class), "{path}");
}
for agent in [
".claude/settings.json",
".claude/settings.local.json",
".claude/skills/x/SKILL.md",
".agents/skills/y/SKILL.md",
".codex/config.toml",
".kiro/hooks/a.json",
".mcp.json",
"web/.mcp.json",
"opencode.json",
] {
assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
}
for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
assert_eq!(p.write_class(context), None, "{context}");
assert!(p.is_context(context), "{context}");
}
assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
assert_eq!(p.write_class(free), None, "{free}");
}
assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
for t in [
"tests/api.rs",
"crates/x/tests/it.rs",
"app/test_login.py",
"web/a.test.ts",
"web/__tests__/b.js",
"pkg/x_test.go",
] {
assert!(is_test_path(t), "{t}");
}
for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
assert!(!is_test_path(f), "{f}");
}
}
}