butler/tools/butlerignore.py
Rama main · réplica de lectura del repositorio original.
"""Parse ``.butlerignore`` patterns (Cursor ``.cursorignore`` subset)."""
from __future__ import annotations
import fnmatch
import logging
import os
from pathlib import Path
from butler.defaults.env_defaults import SECRETS_PATH_DEFAULT
logger = logging.getLogger(__name__)
_IGNORE_FILENAMES = (".butlerignore",)
_PROTECTED_WRITE_GLOBS = (
".butler/*.json",
".butler/**/*.json",
".git/config",
".git/hooks/**",
".git/info/attributes",
".env",
".env.*",
)
def _ignore_file_candidates(workspace: Path) -> list[Path]:
ws = workspace.resolve(strict=False)
return [ws / name for name in _IGNORE_FILENAMES] + [ws / ".butler" / ".butlerignore"]
def load_ignore_patterns(workspace: Path | None = None) -> tuple[str, ...]:
"""Return normalized glob patterns from workspace ignore files."""
from butler.tools.butlerignore_ops import current_workspace_root_safe
if workspace is None:
workspace = current_workspace_root_safe()
if workspace is None:
return ()
patterns: list[str] = []
for path in _ignore_file_candidates(workspace):
if not path.is_file():
continue
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
raw = line.strip()
if not raw or raw.startswith("#"):
continue
if raw.startswith("!"):
continue
patterns.append(raw.replace("\\", "/"))
except OSError as exc:
logger.debug("butlerignore read skipped %s: %s", path, exc)
return tuple(patterns)
def _rel_posix(path: Path, workspace: Path) -> str | None:
try:
rel = path.resolve(strict=False).relative_to(workspace.resolve(strict=False))
except ValueError:
return None
return rel.as_posix()
def matches_ignore_pattern(rel_posix: str, pattern: str) -> bool:
pat = pattern.strip().replace("\\", "/")
if not pat:
return False
if pat.endswith("/"):
pat = pat.rstrip("/") + "/**"
if "**" in pat:
return fnmatch.fnmatch(rel_posix, pat) or fnmatch.fnmatch(
rel_posix, pat.replace("**", "*")
)
if "/" not in pat:
base = rel_posix.split("/")[-1]
return fnmatch.fnmatch(base, pat) or fnmatch.fnmatch(rel_posix, pat)
return fnmatch.fnmatch(rel_posix, pat)
def is_butlerignored(path: Path, *, workspace: Path | None = None) -> bool:
"""True when ``path`` matches ``.butlerignore`` under ``workspace``."""
from butler.tools.butlerignore_ops import current_workspace_root_safe
if workspace is None:
workspace = current_workspace_root_safe()
if workspace is None:
return False
rel = _rel_posix(path, workspace)
if rel is None:
return False
for pattern in load_ignore_patterns(workspace):
if matches_ignore_pattern(rel, pattern):
return True
return False
def is_protected_write_path(path: Path, *, workspace: Path | None = None) -> bool:
"""Hard-coded write-protected paths (Cursor sandbox protected paths subset)."""
from butler.tools.butlerignore_ops import current_workspace_root_safe
if workspace is None:
workspace = current_workspace_root_safe()
if workspace is None:
return False
rel = _rel_posix(path, workspace)
if rel is None:
return False
for pattern in _PROTECTED_WRITE_GLOBS:
if matches_ignore_pattern(rel, pattern):
return True
return is_butlerignored(path, workspace=workspace)
def credential_mask_paths() -> list[str]:
"""Host credential paths masked inside OS sandbox (tmpfs overlay)."""
home = Path.home()
candidates = [
home / ".ssh",
home / ".aws" / "credentials",
home / ".config" / "gcloud",
home / ".docker" / "config.json",
]
env_path = os.getenv("BUTLER_SECRETS_PATH", SECRETS_PATH_DEFAULT).strip()
if env_path:
candidates.append(Path(env_path).expanduser())
out: list[str] = []
for path in candidates:
try:
resolved = str(path.expanduser().resolve(strict=False))
except OSError:
continue
if Path(resolved).exists() and resolved not in out:
out.append(resolved)
return out
__all__ = [
"credential_mask_paths",
"is_butlerignored",
"is_protected_write_path",
"load_ignore_patterns",
"matches_ignore_pattern",
]