""" ScriptTemplateEngine — renders parameterized PowerShell templates. Template syntax: {{ param_name }} — simple substitution (string-escaped) {{ param | filter }} — substitution with filter applied {% if param %} ... {% endif %} — conditional block Security: all string values are PowerShell-escaped before insertion. Sensitive parameter values are never stored in generation records — use redact_sensitive(). """ import re from typing import Any class ScriptRenderError(Exception): """Raised when a required parameter is missing or rendering fails.""" class ScriptTemplateEngine: # ── Public API ──────────────────────────────────────────────────────── def render(self, body: str, parameters: dict[str, Any]) -> str: """Render a template body with the given parameters. Raises ScriptRenderError on missing required params.""" result = self._process_conditionals(body, parameters) result = self._substitute_params(result, parameters) return result def redact_sensitive(self, parameters: dict[str, Any], sensitive_keys: set[str]) -> dict[str, Any]: """Return a copy of parameters with sensitive values replaced by [REDACTED].""" return { k: "[REDACTED]" if k in sensitive_keys else v for k, v in parameters.items() } # ── Conditional processing ──────────────────────────────────────────── def _process_conditionals(self, body: str, parameters: dict[str, Any]) -> str: """Process {% if param %} ... {% endif %} blocks.""" pattern = re.compile( r'\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}', re.DOTALL ) def replace_block(match: re.Match) -> str: key = match.group(1) content = match.group(2) value = parameters.get(key) # Truthy: non-empty string, non-empty list, True, non-zero number if value is None or value == "" or value == [] or value is False: return "" return content return pattern.sub(replace_block, body) # ── Parameter substitution ──────────────────────────────────────────── # Matches {{ param }} or {{ param | filter }} _PLACEHOLDER = re.compile(r'\{\{\s*(\w+)(?:\s*\|\s*(\w+))?\s*\}\}') def _substitute_params(self, body: str, parameters: dict[str, Any]) -> str: missing: list[str] = [] def replace(match: re.Match) -> str: key = match.group(1) filter_name = match.group(2) if key not in parameters: missing.append(key) return match.group(0) # leave as-is, report at end value = parameters[key] if filter_name: return self._apply_filter(filter_name, value) return self._escape_string(value) result = self._PLACEHOLDER.sub(replace, body) if missing: raise ScriptRenderError( f"Missing required template parameter(s): {', '.join(missing)}" ) return result # ── Filters ────────────────────────────────────────────────────────── def _apply_filter(self, filter_name: str, value: Any) -> str: filters = { "as_secure_string": self._filter_as_secure_string, "as_array": self._filter_as_array, "as_bool": self._filter_as_bool, "escape_single": self._filter_escape_single, } if filter_name not in filters: raise ScriptRenderError(f"Unknown template filter: {filter_name}") return filters[filter_name](value) def _filter_as_secure_string(self, value: Any) -> str: escaped = str(value).replace("'", "''") return f"(ConvertTo-SecureString '{escaped}' -AsPlainText -Force)" def _filter_as_array(self, value: Any) -> str: if not isinstance(value, list): value = [value] items = ",".join(f"'{self._escape_single_quote(str(v))}'" for v in value) return items def _filter_as_bool(self, value: Any) -> str: if isinstance(value, bool): return "$true" if value else "$false" return "$true" if str(value).lower() in ("true", "1", "yes") else "$false" def _filter_escape_single(self, value: Any) -> str: return self._escape_single_quote(str(value)) # ── Escaping helpers ────────────────────────────────────────────────── def _escape_string(self, value: Any) -> str: """Escape a value for safe insertion into a PowerShell single-quoted context.""" if isinstance(value, bool): return "$true" if value else "$false" if isinstance(value, (int, float)): return str(value) if isinstance(value, list): # Lists rendered without filter — join with space (caller should use | as_array for proper PS arrays) return " ".join(str(v) for v in value) s = str(value) # Escape backtick (PS escape char) first, then dollar (variable interpolation) s = s.replace("`", "``") s = s.replace("$", "`$") # Escape single quotes (doubling for PS single-quoted strings) s = self._escape_single_quote(s) return s def _escape_single_quote(self, s: str) -> str: return s.replace("'", "''")