"""ENV file update executor with atomic writes and key validation.""" import asyncio import os import stat import tempfile import time from pathlib import Path from typing import Any from app.config import get_settings from app.executors.base import BaseExecutor, ExecutionResult from app.utils.validation import ValidationError, validate_env_key, validate_file_path class EnvUpdateExecutor(BaseExecutor): """Update ENV files with key-value merging and removal. Security measures: - Path validation against allowed env root (/opt/letsbe/env) - ENV key format validation (^[A-Z][A-Z0-9_]*$) - Atomic writes (temp file + fsync + rename) - Secure permissions (chmod 640) - Directory traversal prevention Payload: { "path": "/opt/letsbe/env/chatwoot.env", "updates": { "DATABASE_URL": "postgres://localhost/mydb", "API_KEY": "secret123" }, "remove_keys": ["OLD_KEY", "DEPRECATED_VAR"] # optional } Result: { "updated_keys": ["DATABASE_URL", "API_KEY"], "removed_keys": ["OLD_KEY"], "path": "/opt/letsbe/env/chatwoot.env" } """ # Secure file permissions: owner rw, group r, others none (640) FILE_MODE = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP # 0o640 @property def task_type(self) -> str: return "ENV_UPDATE" async def execute(self, payload: dict[str, Any]) -> ExecutionResult: """Update ENV file with new key-value pairs and optional removals. Args: payload: Must contain "path" and at least one of "updates" or "remove_keys" Returns: ExecutionResult with lists of updated and removed keys """ # Path is always required if "path" not in payload: raise ValueError("Missing required field: path") settings = get_settings() file_path = payload["path"] updates = payload.get("updates", {}) remove_keys = payload.get("remove_keys", []) # Validate that at least one operation is provided if not updates and not remove_keys: return ExecutionResult( success=False, data={}, error="At least one of 'updates' or 'remove_keys' must be provided", ) # Validate updates is a dict if provided if updates and not isinstance(updates, dict): return ExecutionResult( success=False, data={}, error="'updates' must be a dictionary of key-value pairs", ) # Validate remove_keys is a list if provided if remove_keys and not isinstance(remove_keys, list): return ExecutionResult( success=False, data={}, error="'remove_keys' must be a list of key names", ) # Validate path is under allowed env root try: validated_path = validate_file_path( file_path, settings.allowed_env_root, must_exist=False, ) except ValidationError as e: self.logger.warning("env_path_validation_failed", path=file_path, error=str(e)) return ExecutionResult( success=False, data={}, error=f"Path validation failed: {e}", ) # Validate all update keys match pattern try: for key in updates.keys(): validate_env_key(key) except ValidationError as e: self.logger.warning("env_key_validation_failed", error=str(e)) return ExecutionResult( success=False, data={}, error=str(e), ) # Validate all remove_keys match pattern try: for key in remove_keys: if not isinstance(key, str): raise ValidationError(f"remove_keys must contain strings, got: {type(key).__name__}") validate_env_key(key) except ValidationError as e: self.logger.warning("env_remove_key_validation_failed", error=str(e)) return ExecutionResult( success=False, data={}, error=str(e), ) self.logger.info( "env_updating", path=str(validated_path), update_keys=list(updates.keys()) if updates else [], remove_keys=remove_keys, ) start_time = time.time() try: # Read existing ENV file if it exists existing_env = {} if validated_path.exists(): content = validated_path.read_text(encoding="utf-8") existing_env = self._parse_env_file(content) # Track which keys were actually removed (existed before) actually_removed = [k for k in remove_keys if k in existing_env] # Apply updates (new values overwrite existing) merged_env = {**existing_env, **updates} # Remove specified keys for key in remove_keys: merged_env.pop(key, None) # Serialize and write atomically with secure permissions new_content = self._serialize_env(merged_env) await self._atomic_write_secure(validated_path, new_content.encode("utf-8")) duration_ms = (time.time() - start_time) * 1000 self.logger.info( "env_updated", path=str(validated_path), updated_keys=list(updates.keys()) if updates else [], removed_keys=actually_removed, duration_ms=duration_ms, ) return ExecutionResult( success=True, data={ "updated_keys": list(updates.keys()) if updates else [], "removed_keys": actually_removed, "path": str(validated_path), }, duration_ms=duration_ms, ) except Exception as e: duration_ms = (time.time() - start_time) * 1000 self.logger.error("env_update_error", path=str(validated_path), error=str(e)) return ExecutionResult( success=False, data={}, error=str(e), duration_ms=duration_ms, ) def _parse_env_file(self, content: str) -> dict[str, str]: """Parse ENV file content into key-value dict. Handles: - KEY=value format - Lines starting with # (comments) - Empty lines - Whitespace trimming - Quoted values (single and double quotes) Args: content: Raw ENV file content Returns: Dict of key-value pairs """ env_dict = {} for line in content.splitlines(): line = line.strip() # Skip empty lines and comments if not line or line.startswith("#"): continue # Split on first = only if "=" in line: key, value = line.split("=", 1) key = key.strip() value = value.strip() # Remove surrounding quotes if present if (value.startswith('"') and value.endswith('"')) or \ (value.startswith("'") and value.endswith("'")): value = value[1:-1] env_dict[key] = value return env_dict def _serialize_env(self, env_dict: dict[str, str]) -> str: """Serialize dict to ENV file format. Args: env_dict: Key-value pairs Returns: ENV file content string with sorted keys """ lines = [] for key, value in sorted(env_dict.items()): # Quote values that contain spaces, newlines, or equals signs if " " in str(value) or "\n" in str(value) or "=" in str(value): value = f'"{value}"' lines.append(f"{key}={value}") return "\n".join(lines) + "\n" if lines else "" async def _atomic_write_secure(self, path: Path, content: bytes) -> int: """Write file atomically with secure permissions. Uses temp file + fsync + rename pattern for atomicity. Sets chmod 640 (owner rw, group r, others none) for security. Args: path: Target file path content: Content to write Returns: Number of bytes written """ def _write() -> int: # Ensure parent directory exists path.parent.mkdir(parents=True, exist_ok=True) # Write to temp file in same directory (for atomic rename) fd, temp_path = tempfile.mkstemp( dir=path.parent, prefix=".tmp_", suffix=".env", ) temp_path_obj = Path(temp_path) try: os.write(fd, content) os.fsync(fd) # Ensure data is on disk finally: os.close(fd) # Set secure permissions before rename (640) os.chmod(temp_path, self.FILE_MODE) # Atomic rename os.replace(temp_path_obj, path) return len(content) return await asyncio.to_thread(_write)