"""Resolve local tools once, without installation, network access or server startup.""" from __future__ import annotations import hashlib from itertools import islice import os from pathlib import Path import shutil import stat import subprocess import sys from .common import digest from .workspace import Project from .process import require_capture MAX_DISCOVERY_CHILDREN = 4096 def resolve_tools(workspace_root: Path, project: Project) -> dict[str, str]: configured = project.config.get("tools", {}) if not isinstance(configured, dict) or set(configured) - {"python", "node", "npm"}: raise ValueError("Project tools must configure only python, node and npm") tools = {} python_env = ( Path( os.environ.get("GOVOPLAN_VENV_ROOT", str(workspace_root / "govoplan/.venv")) ) / "bin/python" ) for name in ("python", "node", "npm"): default = ( str(python_env) if name == "python" and python_env.is_file() else sys.executable if name == "python" else name ) value = configured.get(name) or os.environ.get(name.upper()) or default if not isinstance(value, str) or not value or "\0" in value: raise ValueError(f"Invalid executable configuration for {name}") executable = shutil.which(value) if executable: # Keep venv executable symlinks: resolving them loses Python's environment. tools[name] = os.path.abspath(executable) else: tools[name] = value return tools def execution_environment( workspace_root: Path, project: Project, tools: dict[str, str] ) -> dict[str, str]: env = dict(os.environ) if ( project.config.get("organization") == "GovOPlaN" and "schema_version" not in project.config ): # Managed GovOPlaN checks must not inherit another checkout's scope. env["GOVOPLAN_WORKSPACE_ROOT"] = str(workspace_root.resolve()) core = next( repo.path for repo in project.repositories if repo.name == "govoplan-core" ) env["GOVOPLAN_CORE_ROOT"] = str(core) env["GOVOPLAN_CORE_SOURCE_ROOT"] = str(core) directories = [ str(Path(tools[name]).parent) for name in ("python", "node", "npm") if Path(tools[name]).is_absolute() ] core_bins = workspace_root / "govoplan-core/webui/node_modules/.bin" if core_bins.is_dir(): directories.insert(0, str(core_bins)) env["PATH"] = os.pathsep.join([*directories, env.get("PATH", "")]) env.update({name.upper(): value for name, value in tools.items()}) sources = [ str(repo.path / "src") for repo in project.repositories if (repo.path / "src").is_dir() ] if sources: env["PYTHONPATH"] = os.pathsep.join( sources + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []) ) env["GIT_OPTIONAL_LOCKS"] = "0" env["PYTHONDONTWRITEBYTECODE"] = "1" return env def tool_version(executable: str, env: dict[str, str]) -> str: try: result = require_capture( [executable, "--version"], timeout=15, max_stdout=4096, max_stderr=4096, env=env, ) return ( (result.stdout or result.stderr).decode(errors="replace").strip()[:256] if result.returncode == 0 else "unavailable" ) except (OSError, subprocess.TimeoutExpired, ValueError): return "unavailable" def _file_identity(metadata: os.stat_result) -> tuple[int, ...]: return ( metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_size, metadata.st_ctime_ns, metadata.st_mtime_ns, ) def _environment_file_hash(path: Path, maximum: int) -> str | None: """Read a stable, bounded regular target without changing executable spelling. Venv executables and package directories may legitimately be symlinks. Only the read target is resolved; both names and the open descriptor are verified again afterwards. Missing optional metadata stays absent, but a present malformed or concurrently changing entry cannot certify an environment. """ try: original = path.lstat() except FileNotFoundError: return None try: target = path.resolve(strict=True) target_metadata = target.lstat() descriptor = os.open( target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0), ) with os.fdopen(descriptor, "rb") as handle: before = os.fstat(handle.fileno()) if not stat.S_ISREG(before.st_mode) or before.st_size > maximum: raise ValueError("Environment input must be a bounded regular file") identity = _file_identity(before) if identity != _file_identity(target_metadata): raise ValueError("Environment input changed before fingerprinting") hasher, size = hashlib.sha256(), 0 for chunk in iter( lambda: handle.read(min(1024 * 1024, maximum - size + 1)), b"" ): size += len(chunk) if size > maximum: raise ValueError("Environment input grew beyond its size bound") hasher.update(chunk) if ( _file_identity(os.fstat(handle.fileno())) != identity or _file_identity(target.lstat()) != identity or _file_identity(path.lstat()) != _file_identity(original) or path.resolve(strict=True) != target ): raise ValueError("Environment input changed during fingerprinting") return hasher.hexdigest() except (OSError, RuntimeError) as exc: raise ValueError("Environment input cannot be fingerprinted safely") from exc def _discovery_path_shape(path: Path) -> dict: """Directory membership, not timestamps changed by ordinary build outputs.""" try: metadata = path.lstat() except (FileNotFoundError, NotADirectoryError): return {"kind": "missing"} try: resolved = path.resolve() try: target_kind = stat.S_IFMT(resolved.lstat().st_mode) except (FileNotFoundError, NotADirectoryError): target_kind = "missing" if target_kind == stat.S_IFLNK: # Newer pathlib versions can retain a loop with strict=False. raise ValueError("Native source discovery cannot be resolved safely") return { "kind": stat.S_IFMT(metadata.st_mode), "resolved": str(resolved), "target_kind": target_kind, } except (OSError, RuntimeError) as exc: raise ValueError("Native source discovery cannot be resolved safely") from exc def _native_discovery_fingerprint(workspace_root: Path, project: Project) -> str: """Bind glob discovery and registered ownership omitted by narrow Git scopes.""" children = list(islice(workspace_root.iterdir(), MAX_DISCOVERY_CHILDREN + 1)) if len(children) > MAX_DISCOVERY_CHILDREN: raise ValueError("Native source discovery exceeds its bounded ownership audit") def shape(path): return { name: _discovery_path_shape(path / name if name else path) for name in ("", "src", "webui") } return digest( { "version": 1, "siblings": [ {"name": child.name, "shape": shape(child)} for child in sorted(children) if child.name.startswith("govoplan") ], "registered": [ {"name": repo.name, "path": str(repo.path), "shape": shape(repo.path)} for repo in sorted(project.repositories, key=lambda item: item.name) ], } ) def environment_fingerprint( workspace_root: Path, project: Project, tools: dict[str, str], env: dict[str, str] ) -> str: identity = { "environment": { key: value for key, value in env.items() if key not in {"_", "SHLVL", "PWD", "OLDPWD"} }, "tools": {}, } if ( project.config.get("organization") == "GovOPlaN" and "schema_version" not in project.config ): identity["native_source_discovery"] = _native_discovery_fingerprint( workspace_root, project ) for name, executable in tools.items(): binary_hash = _environment_file_hash(Path(executable), 512 * 1024 * 1024) identity["tools"][name] = { "path": executable, "version": tool_version(executable, env), "sha256": binary_hash, } installed = {} for repo in project.repositories: for suffix in ( "node_modules/.package-lock.json", "webui/node_modules/.package-lock.json", ".venv/pyvenv.cfg", ): path = repo.path / suffix value = _environment_file_hash(path, 32 * 1024 * 1024) if value is not None: installed[str(path)] = value try: result = require_capture( [ tools["python"], "-c", "import importlib.metadata,json; print(json.dumps(sorted((d.metadata['Name'],d.version) for d in importlib.metadata.distributions())))", ], timeout=30, max_stdout=1024 * 1024, env=env, ) if result.returncode: raise ValueError("Cannot fingerprint installed Python distributions") installed["python_distributions"] = hashlib.sha256(result.stdout).hexdigest() except (OSError, subprocess.TimeoutExpired) as exc: raise ValueError("Cannot fingerprint Python environment") from exc identity["installed"] = installed return digest(identity)