diff --git a/.github/workflows/codecov-analytics.yml b/.github/workflows/codecov-analytics.yml index 0289348..5a6e522 100644 --- a/.github/workflows/codecov-analytics.yml +++ b/.github/workflows/codecov-analytics.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read id-token: write - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-codecov-analytics.yml@e6d4ce5145e76f65491dfa651d492f5ff3961f41 + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-codecov-analytics.yml@be1d889a693c2fcca3b8061350ad1a61bd777fbd with: repo_slug: ${{ github.repository }} event_name: ${{ github.event_name }} diff --git a/.github/workflows/quality-zero-backlog.yml b/.github/workflows/quality-zero-backlog.yml index 9d10824..de61ec9 100644 --- a/.github/workflows/quality-zero-backlog.yml +++ b/.github/workflows/quality-zero-backlog.yml @@ -12,7 +12,7 @@ jobs: permissions: contents: write pull-requests: write - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-backlog-sweep.yml@cb067b5a04b596deef983f93eac95f227b9dc09c + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-backlog-sweep.yml@be1d889a693c2fcca3b8061350ad1a61bd777fbd with: repo_slug: ${{ github.repository }} lane: quality diff --git a/.github/workflows/quality-zero-gate.yml b/.github/workflows/quality-zero-gate.yml index feaa622..c2f04e8 100644 --- a/.github/workflows/quality-zero-gate.yml +++ b/.github/workflows/quality-zero-gate.yml @@ -14,7 +14,7 @@ jobs: aggregate-gate: permissions: contents: read - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-quality-zero-gate.yml@e6d4ce5145e76f65491dfa651d492f5ff3961f41 + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-quality-zero-gate.yml@be1d889a693c2fcca3b8061350ad1a61bd777fbd with: repo_slug: ${{ github.repository }} event_name: ${{ github.event_name }} diff --git a/.github/workflows/quality-zero-platform.yml b/.github/workflows/quality-zero-platform.yml index 20fee1c..92416b3 100644 --- a/.github/workflows/quality-zero-platform.yml +++ b/.github/workflows/quality-zero-platform.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: read id-token: write - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-scanner-matrix.yml@e6d4ce5145e76f65491dfa651d492f5ff3961f41 + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-scanner-matrix.yml@be1d889a693c2fcca3b8061350ad1a61bd777fbd with: repo_slug: ${{ github.repository }} event_name: ${{ github.event_name }} diff --git a/.github/workflows/quality-zero-remediation.yml b/.github/workflows/quality-zero-remediation.yml index c3e8d7d..b754c32 100644 --- a/.github/workflows/quality-zero-remediation.yml +++ b/.github/workflows/quality-zero-remediation.yml @@ -15,7 +15,7 @@ jobs: contents: write pull-requests: write if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'failure' }} - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-remediation-loop.yml@cb067b5a04b596deef983f93eac95f227b9dc09c + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-remediation-loop.yml@be1d889a693c2fcca3b8061350ad1a61bd777fbd with: repo_slug: ${{ github.repository }} failure_context: Quality Zero Gate diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 0000000..e712d76 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,60 @@ +# This file was automatically generated by `qlty init`. +# You can modify it to suit your needs. +# We recommend you to commit this file to your repository. +# +# This configuration is used by both Qlty CLI and Qlty Cloud. +# +# Qlty CLI -- Code quality toolkit for developers +# Qlty Cloud -- Fully automated Code Health Platform +# +# Try Qlty Cloud: https://qlty.sh +# +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/*.d.ts", + "**/.yarn/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/testdata/**", + "**/vendor/**", + "**/assets/**", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "block" + +[[source]] +name = "default" +default = true diff --git a/env_inspector_core/cli.py b/env_inspector_core/cli.py index 368a3d2..8844848 100644 --- a/env_inspector_core/cli.py +++ b/env_inspector_core/cli.py @@ -184,15 +184,8 @@ def run_cli(argv: Sequence[str] | None = None, *, service: EnvInspectorService | return 0 active_service = service or EnvInspectorService() - if args.command == "list": - try: - _list_records(active_service, args) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 2 - return 0 - handlers = { + "list": _list_records, "set": _set_key, "remove": _remove_key, "export": _export_records, @@ -200,11 +193,14 @@ def run_cli(argv: Sequence[str] | None = None, *, service: EnvInspectorService | "restore": _restore_backup, } handler = handlers.get(args.command) + exit_code = 2 if handler is None: print(f"Unknown command: {args.command}", file=sys.stderr) - return 2 - try: - return handler(active_service, args) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 2 + else: + try: + exit_code = handler(active_service, args) + if args.command == "list": + exit_code = 0 + except ValueError as exc: + print(str(exc), file=sys.stderr) + return exit_code diff --git a/env_inspector_core/providers.py b/env_inspector_core/providers.py index 3c1eb08..c1b28dc 100644 --- a/env_inspector_core/providers.py +++ b/env_inspector_core/providers.py @@ -1,30 +1,30 @@ from __future__ import absolute_import, division -import importlib -import os -import re -import shlex -import shutil -from subprocess import PIPE, CompletedProcess, run # nosec B404 -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, cast +import importlib +import os +import re +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, cast from .constants import ( SOURCE_DOTENV, SOURCE_LINUX_BASHRC, - SOURCE_LINUX_ETC_ENV, - SOURCE_POWERSHELL_PROFILE, - SOURCE_PROCESS, - SOURCE_WINDOWS_MACHINE, - SOURCE_WINDOWS_USER, - SOURCE_WSL_BASHRC, - SOURCE_WSL_DOTENV, - SOURCE_WSL_ETC_ENV, -) -from .models import EnvRecord -from .parsing import parse_bash_exports, parse_dotenv_text, parse_etc_environment -from .path_policy import PathPolicyError, resolve_scan_root -from .secrets import looks_secret + SOURCE_LINUX_ETC_ENV, + SOURCE_POWERSHELL_PROFILE, + SOURCE_PROCESS, + SOURCE_WINDOWS_MACHINE, + SOURCE_WINDOWS_USER, +) +from .models import EnvRecord +from .parsing import parse_bash_exports, parse_dotenv_text, parse_etc_environment +from .path_policy import PathPolicyError, resolve_scan_root +from .secrets import looks_secret + +try: + import env_inspector_core.providers_wsl as _providers_wsl +except ImportError: # pragma: no cover - direct script execution + import providers_wsl as _providers_wsl # type: ignore if TYPE_CHECKING: from typing_extensions import Protocol @@ -35,12 +35,13 @@ class WinregModule(Protocol): KEY_READ: int KEY_SET_VALUE: int KEY_WOW64_64KEY: int - REG_EXPAND_SZ: int - REG_SZ: int - OpenKey: Callable[[Any, str, int, int], Any] - EnumValue: Callable[[Any, int], Tuple[str, Any, Any]] - SetValueEx: Callable[[Any, str, int, int, str], None] - DeleteValue: Callable[[Any, str], None] + REG_EXPAND_SZ: int + REG_SZ: int + OpenKey: Callable[[Any, str, int, int], Any] + EnumValue: Callable[[Any, int], Tuple[str, Any, Any]] + QueryInfoKey: Callable[[Any], Tuple[int, int, int]] + SetValueEx: Callable[[Any, str, int, int, str], None] + DeleteValue: Callable[[Any, str], None] class WslClient(Protocol): @@ -49,11 +50,16 @@ class WslClient(Protocol): read_file: Callable[[str, str], str] scan_dotenv_files: Callable[[str, str, int], List[str]] else: - WinregModule = Any - WslClient = Any - - -_winreg: WinregModule | None + WinregModule = Any + WslClient = Any + + +WslProvider = _providers_wsl.WslProvider +collect_wsl_dotenv_records = _providers_wsl.collect_wsl_dotenv_records +collect_wsl_records = _providers_wsl.collect_wsl_records + + +_winreg: WinregModule | None try: _winreg = cast(WinregModule, importlib.import_module("winreg")) except ModuleNotFoundError: # pragma: no cover - non-Windows @@ -77,10 +83,8 @@ def _require_winreg() -> WinregModule: "backend/.venv", } -_HELPER_DISTRO_RE = re.compile(r"^(docker-desktop|docker-desktop-data)$", re.IGNORECASE) - - -def is_windows() -> bool: +_HELPER_DISTRO_RE = re.compile(r"^(docker-desktop|docker-desktop-data)$", re.IGNORECASE) +def is_windows() -> bool: return os.name == "nt" @@ -157,273 +161,118 @@ def __init__(self) -> None: if not is_windows() or _winreg is None: raise RuntimeError("Windows registry provider only available on Windows.") + @staticmethod + def _scope_details(scope: str, access: int) -> Tuple[Any, str, int]: + registry = _require_winreg() + try: + root, path, scoped_access = { + WindowsRegistryProvider.USER_SCOPE: (registry.HKEY_CURRENT_USER, r"Environment", access), + WindowsRegistryProvider.MACHINE_SCOPE: ( + registry.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + access | getattr(registry, "KEY_WOW64_64KEY", 0), + ), + }[scope] + except KeyError as exc: + raise ValueError(f"Unsupported scope: {scope}") from exc + return root, path, scoped_access + @staticmethod def _scope_to_key(scope: str) -> Tuple[Any, str]: - if scope == WindowsRegistryProvider.USER_SCOPE: - registry = _require_winreg() - return registry.HKEY_CURRENT_USER, r"Environment" - if scope == WindowsRegistryProvider.MACHINE_SCOPE: - registry = _require_winreg() - return registry.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment" - raise ValueError(f"Unsupported scope: {scope}") + root, path, _ = WindowsRegistryProvider._scope_details(scope, 0) + return root, path def list_scope(self, scope: str) -> Dict[str, str]: registry = _require_winreg() - root, path = self._scope_to_key(scope) - access = registry.KEY_READ - if scope == WindowsRegistryProvider.MACHINE_SCOPE: - access |= getattr(registry, "KEY_WOW64_64KEY", 0) + root, path, access = self._scope_details(scope, registry.KEY_READ) - values: Dict[str, str] = {} with registry.OpenKey(root, path, 0, access) as regkey: - index = 0 - while True: - try: - name, value, _ = registry.EnumValue(regkey, index) - except OSError: - break - values[name] = value if isinstance(value, str) else str(value) - index += 1 - return values + return { + name: str(value) + for index in range(registry.QueryInfoKey(regkey)[1]) + for name, value, _ in [registry.EnumValue(regkey, index)] + } def set_scope_value(self, scope: str, key: str, value: str) -> None: registry = _require_winreg() - root, path = self._scope_to_key(scope) - access = registry.KEY_SET_VALUE - if scope == WindowsRegistryProvider.MACHINE_SCOPE: - access |= getattr(registry, "KEY_WOW64_64KEY", 0) + root, path, access = self._scope_details(scope, registry.KEY_SET_VALUE) reg_type = registry.REG_EXPAND_SZ if "%" in value else registry.REG_SZ with registry.OpenKey(root, path, 0, access) as regkey: registry.SetValueEx(regkey, key, 0, reg_type, value) def remove_scope_value(self, scope: str, key: str) -> None: registry = _require_winreg() - root, path = self._scope_to_key(scope) - access = registry.KEY_SET_VALUE - if scope == WindowsRegistryProvider.MACHINE_SCOPE: - access |= getattr(registry, "KEY_WOW64_64KEY", 0) + root, path, access = self._scope_details(scope, registry.KEY_SET_VALUE) with registry.OpenKey(root, path, 0, access) as regkey: - try: + with suppress(FileNotFoundError): registry.DeleteValue(regkey, key) - except FileNotFoundError: - pass def build_registry_records(provider: WindowsRegistryProvider) -> List[EnvRecord]: rows: List[EnvRecord] = [] - for key, value in sorted(provider.list_scope(provider.USER_SCOPE).items(), key=lambda kv: kv[0].lower()): - rows.append( - EnvRecord( - source_type=SOURCE_WINDOWS_USER, - source_id="user", - source_path="HKCU\\Environment", - context="windows", - name=key, - value=value, - is_secret=looks_secret(key, value), - is_persistent=True, - is_mutable=True, - precedence_rank=20, - writable=True, - requires_privilege=False, - last_error=None, - ) - ) - for key, value in sorted(provider.list_scope(provider.MACHINE_SCOPE).items(), key=lambda kv: kv[0].lower()): - rows.append( - EnvRecord( - source_type=SOURCE_WINDOWS_MACHINE, - source_id="machine", - source_path="HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", - context="windows", - name=key, - value=value, - is_secret=looks_secret(key, value), - is_persistent=True, - is_mutable=True, - precedence_rank=30, - writable=True, - requires_privilege=True, - last_error=None, + for source_type, source_id, source_path, scope, precedence_rank, requires_privilege in ( + ( + SOURCE_WINDOWS_USER, + "user", + "HKCU\\Environment", + provider.USER_SCOPE, + 20, + False, + ), + ( + SOURCE_WINDOWS_MACHINE, + "machine", + "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + provider.MACHINE_SCOPE, + 30, + True, + ), + ): + for key, value in sorted(provider.list_scope(scope).items(), key=lambda kv: kv[0].lower()): + rows.append( + EnvRecord( + source_type=source_type, + source_id=source_id, + source_path=source_path, + context="windows", + name=key, + value=value, + is_secret=looks_secret(key, value), + is_persistent=True, + is_mutable=True, + precedence_rank=precedence_rank, + writable=True, + requires_privilege=requires_privilege, + last_error=None, + ) ) - ) return rows -class WslProvider: - def __init__( - self, - runner: Callable[..., CompletedProcess] | None = None, - wsl_exe: str | None = None, - ) -> None: - self.runner = runner or run - self.wsl_exe = wsl_exe or self._discover_wsl_exe() - self._available_cache: bool | None = None - - @staticmethod - def _discover_wsl_exe() -> str | None: - candidates: List[Path] = [] - - if is_windows(): - system_root = os.environ.get("SystemRoot") - if system_root: - candidates.append(Path(system_root) / "System32" / "wsl.exe") - else: - candidates.append(Path("/mnt/c/Windows/System32/wsl.exe")) - - for candidate in candidates: - if candidate.exists(): - return str(candidate) - - for exe_name in ("wsl.exe", "wsl"): - found = shutil.which(exe_name) - if found: - return found - - return None - - def available(self) -> bool: - if self._available_cache is not None: - return self._available_cache - - if not self.wsl_exe: - self._available_cache = False - return False - - try: - proc = self.runner( - [str(self.wsl_exe), "-l", "-q"], - stdout=PIPE, - stderr=PIPE, - check=False, - ) - self._available_cache = proc.returncode == 0 - except OSError: - self._available_cache = False - - return self._available_cache - - @staticmethod - def _decode(data: bytes) -> str: - if not data: - return "" - if b"\x00" in data: - try: - return data.decode("utf-16le").replace("\x00", "") - except UnicodeDecodeError: - return data.decode(errors="ignore") - return data.decode(errors="ignore") - - def _run(self, args: List[str], input_text: str | None = None) -> str: - if not self.available() or not self.wsl_exe: - raise RuntimeError("wsl.exe not available") - proc = self.runner( - [str(self.wsl_exe), *args], - input=(input_text.encode("utf-8") if input_text is not None else None), - stdout=PIPE, - stderr=PIPE, - check=False, - ) - out = self._decode(proc.stdout) - err = self._decode(proc.stderr) - if proc.returncode != 0: - raise RuntimeError((err or out).strip() or f"wsl command failed ({proc.returncode})") - return out - - def list_distros(self) -> List[str]: - text = self._run(["-l", "-q"]) - distros: List[str] = [] - for raw in text.splitlines(): - name = raw.replace("\x00", "").strip().strip("*").strip() - if name: - distros.append(name) - deduped: List[str] = [] - seen: Set[str] = set() - for d in distros: - if d not in seen: - deduped.append(d) - seen.add(d) - return deduped - - def list_distros_for_ui(self) -> List[str]: - return [d for d in self.list_distros() if not _HELPER_DISTRO_RE.match(d)] - - def read_file(self, distro: str, path: str) -> str: - quoted_path = shlex.quote(path) - return self._run(["-d", distro, "-e", "bash", "-lc", f"cat {quoted_path} 2>/dev/null || true"]) - - def write_file(self, distro: str, path: str, content: str) -> None: - quoted_path = shlex.quote(path) - self._run(["-d", distro, "-e", "bash", "-lc", f"cat > {quoted_path}"], input_text=content) - - def write_file_with_privilege(self, distro: str, path: str, content: str) -> None: - quoted_path = shlex.quote(path) - - root_error: RuntimeError | None = None - - # 1) Try direct root user execution. - try: - self._run(["-d", distro, "-u", "root", "-e", "bash", "-lc", f"cat > {quoted_path}"], input_text=content) - return - except RuntimeError as exc: - root_error = exc - - # 2) Fallback to sudo. - try: - self._run(["-d", distro, "-e", "bash", "-lc", f"sudo tee {quoted_path} >/dev/null"], input_text=content) - return - except RuntimeError as exc: - cause = exc if root_error is None else root_error - raise RuntimeError( - "Failed to write with both root and sudo fallback. Run app as admin or configure sudo/root access." - ) from cause - - def scan_dotenv_files(self, distro: str, root_path: str, max_depth: int) -> List[str]: - quoted_root = shlex.quote(root_path) - command = ( - f"find {quoted_root} -maxdepth {max_depth} -type f " - "\\( -name '.env' -o -name '.env.*' \\) 2>/dev/null" - ) - text = self._run(["-d", distro, "-e", "bash", "-lc", command]) - return [line.strip() for line in text.splitlines() if line.strip()] - - - def _normalize_powershell_assignment_value(raw_value: str) -> str: - value = raw_value.strip() - if value.endswith(";"): - value = value[:-1].strip() - if len(value) >= 2 and ((value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")): + value = raw_value.strip().rstrip(";").strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: return value[1:-1] return value -def _is_valid_powershell_env_key(key: str) -> bool: - if not key: - return False - if not (key[0].isalpha() or key[0] == "_"): - return False - return all(char.isalnum() or char == "_" for char in key[1:]) - +def _is_valid_powershell_env_key(key: str) -> bool: + return re.fullmatch(r"[A-Za-z_]\w*", key) is not None -def _parse_powershell_assignment(line: str) -> Optional[Tuple[str, str]]: - stripped = line.strip() - if not stripped or stripped.startswith("#"): - return None - if not stripped.lower().startswith("$env:"): - return None - assignment = stripped[len("$env:") :] - if "=" not in assignment: - return None - - key_part, value_part = assignment.split("=", 1) - key = key_part.strip() - if not _is_valid_powershell_env_key(key): - return None - - value = _normalize_powershell_assignment_value(value_part) - return key, value +def _parse_powershell_assignment(line: str) -> Optional[Tuple[str, str]]: + stripped = line.lstrip() + if not stripped or stripped.startswith("#") or not stripped.startswith("$env:"): + return None + body = stripped[len("$env:") :] + separator = body.find("=") + if separator < 0: + return None + key = body[:separator].strip() + if not _is_valid_powershell_env_key(key): + return None + value = body[separator + 1 :].strip() + return key, _normalize_powershell_assignment_value(value) def parse_powershell_profile_text(text: str) -> List[Tuple[str, str]]: @@ -500,148 +349,32 @@ def collect_linux_records( rows: List[EnvRecord] = [] bashrc = bashrc_path or (Path.home() / ".bashrc") - if bashrc.exists(): - bash_text = bashrc.read_text(encoding="utf-8", errors="ignore") - for key, value in parse_bash_exports(bash_text).items(): - rows.append( - EnvRecord( - source_type=SOURCE_LINUX_BASHRC, - source_id="linux", - source_path=str(bashrc), - context=context, - name=key, - value=value, - is_secret=looks_secret(key, value), - is_persistent=True, - is_mutable=True, - precedence_rank=20, - writable=True, - requires_privilege=False, - last_error=None, - ) - ) - etc_env = etc_environment_path or Path("/etc/environment") - if etc_env.exists(): - etc_text = etc_env.read_text(encoding="utf-8", errors="ignore") - for key, value in parse_etc_environment(etc_text).items(): + for source_type, path, parser, precedence_rank, requires_privilege in [ + spec + for spec in ( + (SOURCE_LINUX_BASHRC, bashrc, parse_bash_exports, 20, False), + (SOURCE_LINUX_ETC_ENV, etc_env, parse_etc_environment, 30, True), + ) + if spec[1].exists() + ]: + text = path.read_text(encoding="utf-8", errors="ignore") + for key, value in parser(text).items(): rows.append( EnvRecord( - source_type=SOURCE_LINUX_ETC_ENV, + source_type=source_type, source_id="linux", - source_path=str(etc_env), + source_path=str(path), context=context, name=key, value=value, is_secret=looks_secret(key, value), is_persistent=True, is_mutable=True, - precedence_rank=30, - writable=True, - requires_privilege=True, - last_error=None, - ) - ) - return rows - - -def _append_wsl_records( - rows: List[EnvRecord], - *, - distro: str, - context: str, - source_type: str, - source_path: str, - pairs: Dict[str, str], - precedence_rank: int, - requires_privilege: bool, -) -> None: - for key, value in pairs.items(): - rows.append( - EnvRecord( - source_type=source_type, - source_id=distro, - source_path=source_path, - context=context, - name=key, - value=value, - is_secret=looks_secret(key, value), - is_persistent=True, - is_mutable=True, - precedence_rank=precedence_rank, - writable=True, - requires_privilege=requires_privilege, - last_error=None, - ) - ) - - -def collect_wsl_records( - wsl: WslClient, - include_etc: bool = True, - exclude_distros: Set[str] | None = None, -) -> List[EnvRecord]: - rows: List[EnvRecord] = [] - if not wsl.available(): - return rows - - excluded = {x.lower() for x in (exclude_distros or set())} - for distro in wsl.list_distros(): - if distro.lower() in excluded: - continue - - context = f"wsl:{distro}" - bash_pairs = parse_bash_exports(wsl.read_file(distro, "~/.bashrc")) - _append_wsl_records( - rows, - distro=distro, - context=context, - source_type=SOURCE_WSL_BASHRC, - source_path=f"{distro}:~/.bashrc", - pairs=bash_pairs, - precedence_rank=20, - requires_privilege=False, - ) - - if include_etc: - etc_pairs = parse_etc_environment(wsl.read_file(distro, "/etc/environment")) - _append_wsl_records( - rows, - distro=distro, - context=context, - source_type=SOURCE_WSL_ETC_ENV, - source_path=f"{distro}:/etc/environment", - pairs=etc_pairs, - precedence_rank=10, - requires_privilege=True, - ) - return rows - - -def collect_wsl_dotenv_records(wsl: WslClient, distro: str, root_path: str, max_depth: int) -> List[EnvRecord]: - rows: List[EnvRecord] = [] - if not wsl.available(): - return rows - for path in wsl.scan_dotenv_files(distro, root_path, max_depth): - text = wsl.read_file(distro, path) - for key, value in parse_dotenv_text(text): - rows.append( - EnvRecord( - source_type=SOURCE_WSL_DOTENV, - source_id=distro, - source_path=f"{distro}:{path}", - context=f"wsl:{distro}", - name=key, - value=value, - is_secret=looks_secret(key, value), - is_persistent=True, - is_mutable=True, - precedence_rank=30, + precedence_rank=precedence_rank, writable=True, - requires_privilege=False, + requires_privilege=requires_privilege, last_error=None, ) ) return rows - - diff --git a/env_inspector_core/providers_wsl.py b/env_inspector_core/providers_wsl.py new file mode 100644 index 0000000..530ae8e --- /dev/null +++ b/env_inspector_core/providers_wsl.py @@ -0,0 +1,234 @@ +from __future__ import absolute_import, division + +import os +import re +import shlex +import shutil +from dataclasses import dataclass +from pathlib import Path +from subprocess import PIPE, CompletedProcess, run # nosec B404 +from typing import Callable, Dict, List, Set + +from .constants import SOURCE_WSL_BASHRC, SOURCE_WSL_DOTENV, SOURCE_WSL_ETC_ENV +from .models import EnvRecord +from .parsing import parse_bash_exports, parse_dotenv_text, parse_etc_environment +from .secrets import looks_secret + +_HELPER_DISTRO_RE = re.compile(r"^(docker-desktop|docker-desktop-data)$", re.IGNORECASE) + + +class WslProvider: + def __init__( + self, + runner: Callable[..., CompletedProcess] | None = None, + wsl_exe: str | None = None, + ) -> None: + self.runner = runner or run + self.wsl_exe = wsl_exe or self._discover_wsl_exe() + self._available_cache: bool | None = None + + @staticmethod + def _discover_wsl_exe() -> str | None: + candidate_paths = ( + Path(system_root) / "System32" / "wsl.exe" if (system_root := os.environ.get("SystemRoot")) else None, + Path("/mnt/c/Windows/System32/wsl.exe") if os.name != "nt" else None, + ) + discovered = next((candidate for candidate in filter(None, candidate_paths) if candidate.exists()), None) + return (str(discovered) if discovered is not None else None) or shutil.which("wsl.exe") or shutil.which("wsl") + + def available(self) -> bool: + if self._available_cache is not None: + return self._available_cache + + try: + self._available_cache = bool( + self.wsl_exe + and self.runner( + [str(self.wsl_exe), "-l", "-q"], + stdout=PIPE, + stderr=PIPE, + check=False, + ).returncode + == 0 + ) + except OSError: + self._available_cache = False + + return self._available_cache + + @staticmethod + def _decode(data: bytes) -> str: + if not data: + return "" + if b"\x00" in data: + try: + return data.decode("utf-16le").replace("\x00", "") + except UnicodeDecodeError: + return data.decode(errors="ignore") + return data.decode(errors="ignore") + + def _run(self, args: List[str], input_text: str | None = None) -> str: + if not self.available(): + raise RuntimeError("wsl.exe not available") + if self.wsl_exe is None: # pragma: no cover - guarded by available() + raise RuntimeError("wsl.exe path unavailable") + proc = self.runner( + [str(self.wsl_exe), *args], + input=(input_text.encode("utf-8") if input_text is not None else None), + stdout=PIPE, + stderr=PIPE, + check=False, + ) + out = self._decode(proc.stdout) + err = self._decode(proc.stderr) + if proc.returncode != 0: + raise RuntimeError((err or out).strip() or f"wsl command failed ({proc.returncode})") + return out + + def list_distros(self) -> List[str]: + text = self._run(["-l", "-q"]) + return list( + dict.fromkeys( + name + for name in ( + raw.replace("\x00", "").strip().strip("*").strip() + for raw in text.splitlines() + ) + if name + ) + ) + + def list_distros_for_ui(self) -> List[str]: + return [d for d in self.list_distros() if not _HELPER_DISTRO_RE.match(d)] + + def read_file(self, distro: str, path: str) -> str: + quoted_path = shlex.quote(path) + return self._run(["-d", distro, "-e", "bash", "-lc", f"cat {quoted_path} 2>/dev/null || true"]) + + def write_file(self, distro: str, path: str, content: str) -> None: + quoted_path = shlex.quote(path) + self._run(["-d", distro, "-e", "bash", "-lc", f"cat > {quoted_path}"], input_text=content) + + def write_file_with_privilege(self, distro: str, path: str, content: str) -> None: + quoted_path = shlex.quote(path) + attempts = ( + ["-d", distro, "-u", "root", "-e", "bash", "-lc", f"cat > {quoted_path}"], + ["-d", distro, "-e", "bash", "-lc", f"sudo tee {quoted_path} >/dev/null"], + ) + root_error: RuntimeError | None = None + for args in attempts: + try: + self._run(args, input_text=content) + return + except RuntimeError as exc: + root_error = exc + raise RuntimeError( + "Failed to write with both root and sudo fallback. Run app as admin or configure sudo/root access." + ) from root_error + + def scan_dotenv_files(self, distro: str, root_path: str, max_depth: int) -> List[str]: + quoted_root = shlex.quote(root_path) + command = ( + f"find {quoted_root} -maxdepth {max_depth} -type f " + "\\( -name '.env' -o -name '.env.*' \\) 2>/dev/null" + ) + text = self._run(["-d", distro, "-e", "bash", "-lc", command]) + return [line.strip() for line in text.splitlines() if line.strip()] + + +@dataclass(frozen=True) +class _WslRecordBatch: + distro: str + context: str + source_type: str + source_path: str + pairs: Dict[str, str] + precedence_rank: int + requires_privilege: bool + + +def _append_wsl_records(rows: List[EnvRecord], batch: _WslRecordBatch) -> None: + for key, value in batch.pairs.items(): + rows.append( + EnvRecord( + source_type=batch.source_type, + source_id=batch.distro, + source_path=batch.source_path, + context=batch.context, + name=key, + value=value, + is_secret=looks_secret(key, value), + is_persistent=True, + is_mutable=True, + precedence_rank=batch.precedence_rank, + writable=True, + requires_privilege=batch.requires_privilege, + last_error=None, + ) + ) + + +def collect_wsl_records( + wsl: WslProvider, + include_etc: bool = True, + exclude_distros: Set[str] | None = None, +) -> List[EnvRecord]: + rows: List[EnvRecord] = [] + if not wsl.available(): + return rows + + excluded = {x.lower() for x in (exclude_distros or set())} + for distro in wsl.list_distros(): + if distro.lower() in excluded: + continue + context = f"wsl:{distro}" + batches = ( + _WslRecordBatch( + distro=distro, + context=context, + source_type=SOURCE_WSL_BASHRC, + source_path=f"{distro}:~/.bashrc", + pairs=parse_bash_exports(wsl.read_file(distro, "~/.bashrc")), + precedence_rank=20, + requires_privilege=False, + ), + _WslRecordBatch( + distro=distro, + context=context, + source_type=SOURCE_WSL_ETC_ENV, + source_path=f"{distro}:/etc/environment", + pairs=parse_etc_environment(wsl.read_file(distro, "/etc/environment")), + precedence_rank=10, + requires_privilege=True, + ), + ) + for batch in batches[: 1 + int(include_etc)]: + _append_wsl_records(rows, batch) + return rows + + +def collect_wsl_dotenv_records(wsl: WslProvider, distro: str, root_path: str, max_depth: int) -> List[EnvRecord]: + rows: List[EnvRecord] = [] + if not wsl.available(): + return rows + for path in wsl.scan_dotenv_files(distro, root_path, max_depth): + text = wsl.read_file(distro, path) + for key, value in parse_dotenv_text(text): + rows.append( + EnvRecord( + source_type=SOURCE_WSL_DOTENV, + source_id=distro, + source_path=f"{distro}:{path}", + context=f"wsl:{distro}", + name=key, + value=value, + is_secret=looks_secret(key, value), + is_persistent=True, + is_mutable=True, + precedence_rank=30, + writable=True, + requires_privilege=False, + last_error=None, + ) + ) + return rows diff --git a/env_inspector_core/service.py b/env_inspector_core/service.py index 8623ccb..27f9aad 100644 --- a/env_inspector_core/service.py +++ b/env_inspector_core/service.py @@ -3,6 +3,7 @@ import json import os import uuid +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Sequence, Tuple, Type @@ -57,11 +58,12 @@ ) from .service_ops import ( diff_text as _diff_text_helper, - make_operation_result as _make_operation_result_helper, masked_value as _masked_value_helper, OperationResultInput, operation_error_types as _operation_error_types_helper, operation_result as _operation_result_helper, + normalize_target_operation_batch as _normalize_target_operation_batch_helper, + normalize_target_operation_request as _normalize_target_operation_request_helper, ) from .service_privileged import ( run as _privileged_run, @@ -82,6 +84,7 @@ validate_wsl_distro_name as _validate_wsl_distro_name_helper, validate_wsl_dotenv_path as _validate_wsl_dotenv_path_helper, ) +from . import service_aliases as _service_aliases from .service_paths import ( get_powershell_profile_paths as _get_powershell_profile_paths, is_path_within as _is_path_within, @@ -119,6 +122,43 @@ def _read_text_if_exists(path: Path) -> str: return handle.read() +@dataclass(frozen=True) +class TargetOperationRequest: + target: str + key: str + value: str | None + action: str + scope_roots: Sequence[Path] + + +@dataclass(frozen=True) +class TargetOperationBatch: + action: str + key: str + value: str | None + targets: List[str] + scope_roots: List[str | Path] | None = None + + +@dataclass(frozen=True) +class ListRecordsRequest: + root: str | Path | None = None + context: str | None = None + source: List[str] | None = None + wsl_path: str | None = None + distro: str | None = None + scan_depth: int = DEFAULT_SCAN_DEPTH + include_raw_secrets: bool = False + + +@dataclass(frozen=True) +class ShellMutationRequest: + key: str + value: str | None + action: str + style: str + + class EnvInspectorService: _LINUX_ETC_ENV_PATH = "/etc/environment" @@ -144,72 +184,18 @@ def _effective_scope_roots(self, scope_roots: List[str | Path] | None = None) -> roots.extend(normalize_scope_roots(scope_roots)) return normalize_scope_roots(roots) - @staticmethod - def get_powershell_profile_paths() -> List[Path]: - return _get_powershell_profile_paths() + def resolve_effective(self, key: str, context: str, records: List[EnvRecord]) -> EnvRecord | None: + return resolve_effective_value(records, key, context) + + + - @staticmethod - def _is_path_within(path: Path, root: Path) -> bool: - return _is_path_within(path, root) - @classmethod - def _validate_path_in_roots(cls, path: Path, roots: Sequence[Path], *, label: str) -> Path: - return _validate_path_in_roots(path, roots, label=label) - @staticmethod - def _write_text_file(path: Path, text: str, *, ensure_parent: bool) -> None: - _write_text_file(path, text, ensure_parent=ensure_parent) - @staticmethod - def _write_scoped_text_file( - *, - candidate_path: Path, - allowed_roots: Sequence[Path], - text: str, - label: str, - ) -> Path: - return _write_scoped_text_file( - candidate_path=candidate_path, - allowed_roots=allowed_roots, - text=text, - label=label, - ) - def _powershell_target_path_and_roots(self, target: str) -> Tuple[Path, List[Path], bool]: - return _powershell_target_path_and_roots( - target, - profile_resolver=self._powershell_profile_path, - current_user_target=TARGET_POWERSHELL_CURRENT_USER, - all_users_target=TARGET_POWERSHELL_ALL_USERS, - ) - def _validated_powershell_restore_path(self, target: str) -> Path: - return _validated_powershell_restore_path( - target, - profile_resolver=self._powershell_profile_path, - current_user_target=TARGET_POWERSHELL_CURRENT_USER, - all_users_target=TARGET_POWERSHELL_ALL_USERS, - ) - @classmethod - def _linux_etc_environment_path(cls) -> Path: - return _linux_etc_environment_path(cls._LINUX_ETC_ENV_PATH) - - def list_contexts(self) -> List[str]: - contexts = [self.runtime_context] - if self.wsl.available(): - for distro in self._bridge_distros(): - contexts.append(f"wsl:{distro}") - return contexts - - def _bridge_distros(self) -> List[str]: - if not self.wsl.available(): - return [] - distros = self.wsl.list_distros_for_ui() - if self.runtime_context == "linux" and self.current_wsl_distro: - current = self.current_wsl_distro.lower() - distros = [d for d in distros if d.lower() != current] - return distros def _collect_host_rows(self, root_path: Path, scan_depth: int) -> List[EnvRecord]: return _collect_host_rows_helper( @@ -247,97 +233,34 @@ def _collect_wsl_rows( collect_wsl_dotenv_records_fn=collect_wsl_dotenv_records, ) - @staticmethod - def _apply_row_filters( - rows: List[EnvRecord], - *, - source: List[str] | None, - context: str | None, - ) -> List[EnvRecord]: - return _apply_row_filters_helper(rows, source=source, context=context) def list_records( self, - *, - root: str | Path | None = None, - context: str | None = None, - source: List[str] | None = None, - wsl_path: str | None = None, - distro: str | None = None, - scan_depth: int = DEFAULT_SCAN_DEPTH, - include_raw_secrets: bool = False, + request: ListRecordsRequest | None = None, + **kwargs: Any, ) -> List[Dict[str, Any]]: - root_path = resolve_scan_root(root or Path.cwd()) - rows = self._collect_host_rows(root_path, scan_depth) - rows.extend(self._collect_wsl_rows(scan_depth=scan_depth, distro=distro, wsl_path=wsl_path)) - rows = self._apply_row_filters(rows, source=source, context=context) + if request is None: + request = ListRecordsRequest(**kwargs) + elif kwargs: + raise TypeError("Pass either a ListRecordsRequest or keyword arguments, not both.") + + root_path = resolve_scan_root(request.root or Path.cwd()) + rows = self._collect_host_rows(root_path, request.scan_depth) + rows.extend( + self._collect_wsl_rows(scan_depth=request.scan_depth, distro=request.distro, wsl_path=request.wsl_path) + ) + rows = self._apply_row_filters(rows, source=request.source, context=request.context) rows.sort(key=lambda r: (r.name.lower(), r.context, r.source_type, r.source_path)) - return _rows_to_payload_helper(rows, include_raw_secrets=include_raw_secrets) + return _rows_to_payload_helper(rows, include_raw_secrets=request.include_raw_secrets) - def list_records_raw(self, **kwargs: Any) -> List[EnvRecord]: - payload = self.list_records(include_raw_secrets=True, **kwargs) - return [EnvRecord(**item) for item in payload] - @staticmethod - def resolve_effective(key: str, context: str, records: List[EnvRecord]) -> EnvRecord | None: - return resolve_effective_value(records, key, context) - @staticmethod - def _diff(before: str, after: str, target: str) -> str: - return _diff_text_helper(before, after, target) - @classmethod - def _write_linux_etc_environment_with_privilege(cls, text: str) -> None: - _write_linux_etc_environment_with_privilege_helper( - fixed_path=cls._LINUX_ETC_ENV_PATH, - expected_path=LINUX_ETC_ENV_PATH, - text=text, - write_text_file=lambda path, payload: cls._write_text_file(path, payload, ensure_parent=False), - which_fn=which, - run_fn=run, - ) - def available_targets(self, records: List[EnvRecord], context: str | None = None) -> List[str]: - return _available_targets_helper( - records, - context=context, - win_provider_present=self.win_provider is not None, - ) - @staticmethod - def _powershell_target_for_path(source_path: str) -> str: - return _powershell_target_for_path_helper(source_path) - @classmethod - def _record_target(cls, record: EnvRecord) -> str | None: - return _record_target_helper(record) - def _registry_write( - self, - target: str, - key: str, - value: str | None, - action: str, - *, - apply_changes: bool, - ) -> Tuple[str, str, str | None, bool, str | None]: - if self.win_provider is None: - raise RuntimeError("Windows registry provider unavailable.") - scope = WindowsRegistryProvider.USER_SCOPE if target == TARGET_WINDOWS_USER else WindowsRegistryProvider.MACHINE_SCOPE - current = self.win_provider.list_scope(scope) - before = json.dumps(current, indent=2, sort_keys=True) - if action == "set" and value is not None: - if apply_changes: - self.win_provider.set_scope_value(scope, key, value) - current[key] = value - elif action == "remove": - if apply_changes: - self.win_provider.remove_scope_value(scope, key) - current.pop(key, None) - after = json.dumps(current, indent=2, sort_keys=True) - requires_priv = target == TARGET_WINDOWS_MACHINE - return before, after, None, requires_priv, None def _powershell_profile_path(self, target: str) -> Path: current, all_users = self.get_powershell_profile_paths() @@ -349,18 +272,26 @@ def _powershell_profile_path(self, target: str) -> Path: def _update_dotenv_file( self, - *, - target: str, - key: str, - value: str | None, - action: str, + *args: Any, apply_changes: bool, - scope_roots: List[Path], + **kwargs: Any, ) -> Tuple[str, str, str | None, bool, str | None]: - scoped = parse_scoped_dotenv_target(target, roots=scope_roots) + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + scoped = parse_scoped_dotenv_target(request.target, roots=list(request.scope_roots)) path = self._validate_path_in_roots(scoped.path, list(scoped.roots), label="dotenv target path") before = _read_text_if_exists(path) - after = upsert_key_value(before, key, value or "", quote=False) if action == "set" else remove_key_value(before, key) + after = ( + upsert_key_value(before, request.key, request.value or "", quote=False) + if request.action == "set" + else remove_key_value(before, request.key) + ) if apply_changes: self._write_scoped_text_file( candidate_path=scoped.path, @@ -370,48 +301,59 @@ def _update_dotenv_file( ) return before, after, str(path), False, None - @staticmethod - def _mutate_shell_content(before: str, *, key: str, value: str | None, action: str, style: str) -> str: - if action != "set": - return remove_export(before, key) if style == "export" else remove_key_value(before, key) - if style == "export": - return upsert_export(before, key, value or "") - return upsert_key_value(before, key, value or "", quote=False) + def _mutate_shell_content(self, before: str, request: ShellMutationRequest) -> str: + if request.action != "set": + return remove_export(before, request.key) if request.style == "export" else remove_key_value(before, request.key) + if request.style == "export": + return upsert_export(before, request.key, request.value or "") + return upsert_key_value(before, request.key, request.value or "", quote=False) def _update_linux_file( self, - *, - target: str, - key: str, - value: str | None, - action: str, + *args: Any, apply_changes: bool, + **kwargs: Any, ) -> Tuple[str, str, str | None, bool, str | None]: - if target == TARGET_LINUX_BASHRC: + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + if request.target == TARGET_LINUX_BASHRC: bashrc_path = Path.home() / ".bashrc" before = _read_text_if_exists(bashrc_path) - after = self._mutate_shell_content(before, key=key, value=value, action=action, style="export") + after = self._mutate_shell_content(before, ShellMutationRequest(request.key, request.value, request.action, "export")) if apply_changes: self._write_text_file(bashrc_path, after, ensure_parent=True) return before, after, str(bashrc_path), False, None - if target == TARGET_LINUX_ETC_ENV: + if request.target == TARGET_LINUX_ETC_ENV: etc_path = self._linux_etc_environment_path() before = _read_text_if_exists(etc_path) - after = self._mutate_shell_content(before, key=key, value=value, action=action, style="key_value") + after = self._mutate_shell_content( + before, + ShellMutationRequest(request.key, request.value, request.action, "key_value"), + ) if apply_changes: self._write_linux_etc_environment_with_privilege(after) return before, after, self._LINUX_ETC_ENV_PATH, True, None - raise RuntimeError(f"Unsupported Linux target: {target}") + raise RuntimeError(f"Unsupported Linux target: {request.target}") + + def _write_linux_etc_environment_with_privilege(self, text: str) -> None: + _write_linux_etc_environment_with_privilege_helper( + fixed_path=LINUX_ETC_ENV_PATH, + expected_path=self._LINUX_ETC_ENV_PATH, + text=text, + write_text_file=lambda path, payload: self._write_text_file(path, payload, ensure_parent=False), + which_fn=which, + run_fn=run, + ) - @staticmethod - def _validate_wsl_distro_name(raw: str) -> str: - return _validate_wsl_distro_name_helper(raw) - @staticmethod - def _validate_wsl_dotenv_path(raw: str) -> str: - return _validate_wsl_dotenv_path_helper(raw, path_error=WSL_DOTENV_PATH_ERROR) def _parse_wsl_dotenv_target(self, target: str) -> Tuple[str, str]: return _parse_wsl_dotenv_target_helper( @@ -432,16 +374,21 @@ def _resolve_wsl_target(self, target: str) -> Tuple[str, str, str, bool]: def _update_wsl_file( self, - *, - target: str, - key: str, - value: str | None, - action: str, + *args: Any, apply_changes: bool, + **kwargs: Any, ) -> Tuple[str, str, str | None, bool, str | None]: - distro, path, style, requires_priv = self._resolve_wsl_target(target) + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + distro, path, style, requires_priv = self._resolve_wsl_target(request.target) before = self.wsl.read_file(distro, path) - after = self._mutate_shell_content(before, key=key, value=value, action=action, style=style) + after = self._mutate_shell_content(before, ShellMutationRequest(request.key, request.value, request.action, style)) if apply_changes: writer = self.wsl.write_file_with_privilege if requires_priv else self.wsl.write_file @@ -451,20 +398,25 @@ def _update_wsl_file( def _update_powershell_file( self, - *, - target: str, - key: str, - value: str | None, - action: str, + *args: Any, apply_changes: bool, - ) -> Tuple[str, str, str | None, bool, str | None]: - profile, allowed_roots, requires_priv = self._powershell_target_path_and_roots(target) + **kwargs: Any, + ) -> Tuple[str, str, str | None, bool, str | None]: + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + profile, allowed_roots, requires_priv = self._powershell_target_path_and_roots(request.target) safe_profile = self._validate_path_in_roots(profile, allowed_roots, label="PowerShell profile path") before = _read_text_if_exists(safe_profile) after = ( - upsert_powershell_env(before, key, value or "") - if action == "set" - else remove_powershell_env(before, key) + upsert_powershell_env(before, request.key, request.value or "") + if request.action == "set" + else remove_powershell_env(before, request.key) ) if apply_changes: self._write_text_file(safe_profile, after, ensure_parent=True) @@ -472,62 +424,45 @@ def _update_powershell_file( def _file_update( self, - target: str, - key: str, - value: str | None, - action: str, - *, + *args: Any, apply_changes: bool, - scope_roots: List[Path], + **kwargs: Any, ) -> Tuple[str, str, str | None, bool, str | None]: - if target.startswith(DOTENV_TARGET_PREFIX): - return self._update_dotenv_file( - target=target, - key=key, - value=value, - action=action, - apply_changes=apply_changes, - scope_roots=scope_roots, - ) - if target.startswith("linux:"): - return self._update_linux_file( - target=target, - key=key, - value=value, - action=action, - apply_changes=apply_changes, - ) - if target.startswith("wsl"): - return self._update_wsl_file( - target=target, - key=key, - value=value, - action=action, - apply_changes=apply_changes, - ) - if target.startswith("powershell:"): - return self._update_powershell_file( - target=target, - key=key, - value=value, - action=action, - apply_changes=apply_changes, - ) - raise RuntimeError(f"Unsupported target: {target}") + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + if request.target.startswith(DOTENV_TARGET_PREFIX): + return self._update_dotenv_file(request=request, apply_changes=apply_changes) + if request.target.startswith("linux:"): + return self._update_linux_file(request=request, apply_changes=apply_changes) + if request.target.startswith("wsl"): + return self._update_wsl_file(request=request, apply_changes=apply_changes) + if request.target.startswith("powershell:"): + return self._update_powershell_file(request=request, apply_changes=apply_changes) + raise RuntimeError(f"Unsupported target: {request.target}") def _plan_target_operation( self, - target: str, - key: str, - value: str | None, - action: str, - *, + *args: Any, apply_changes: bool, - scope_roots: List[Path], + **kwargs: Any, ) -> Tuple[str, str, str | None, bool, str | None]: - if target in {TARGET_WINDOWS_USER, TARGET_WINDOWS_MACHINE}: - return self._registry_write(target, key, value, action, apply_changes=apply_changes) - return self._file_update(target, key, value, action, apply_changes=apply_changes, scope_roots=scope_roots) + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + if request.target in {TARGET_WINDOWS_USER, TARGET_WINDOWS_MACHINE}: + return self._registry_write(request=request, apply_changes=apply_changes) + return self._file_update(request=request, apply_changes=apply_changes) def _validate_target_for_operation(self, target: str, *, scope_roots: List[Path]) -> None: if target in { @@ -550,106 +485,72 @@ def _validate_target_for_operation(self, target: str, *, scope_roots: List[Path] return raise RuntimeError(f"Unsupported target: {target}") - @staticmethod - def _masked_value(*, secret_operation: bool, value: str | None) -> str | None: - return _masked_value_helper(secret_operation=secret_operation, value=value) - @staticmethod - def _make_operation_result( - *, - operation_id: str, - target: str, - action: str, - success: bool, - backup_path: str | None, - diff_preview: str, - error_message: str | None, - value_masked: str | None, - ) -> OperationResult: - return _make_operation_result_helper( - operation_id=operation_id, - target=target, - action=action, - success=success, - backup_path=backup_path, - diff_preview=diff_preview, - error_message=error_message, - value_masked=value_masked, - ) - @staticmethod - def _operation_error_types() -> Tuple[Type[BaseException], ...]: - return _operation_error_types_helper() def _preview_target_diff( self, - *, - target: str, - key: str, - value: str | None, - action: str, - resolved_scope_roots: Sequence[Path], + *args: Any, + **kwargs: Any, ) -> Tuple[str, str]: - self._validate_target_for_operation(target, scope_roots=list(resolved_scope_roots)) - before, after, _, _, _ = self._plan_target_operation( - target=target, - key=key, - value=value, - action=action, - apply_changes=False, - scope_roots=list(resolved_scope_roots), + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], ) - return before, self._diff(before, after, target) + self._validate_target_for_operation(request.target, scope_roots=list(request.scope_roots)) + before, after, _, _, _ = self._plan_target_operation(request=request, apply_changes=False) + return before, self._diff(before, after, request.target) def _apply_target_operation( self, - *, - target: str, - key: str, - value: str | None, - action: str, + *args: Any, before: str, - resolved_scope_roots: Sequence[Path], + **kwargs: Any, ) -> str: - backup_path = str(self.backup_mgr.backup_text(target, before)) - self._plan_target_operation( - target=target, - key=key, - value=value, - action=action, - apply_changes=True, - scope_roots=list(resolved_scope_roots), + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], ) + backup_path = str(self.backup_mgr.backup_text(request.target, before)) + self._plan_target_operation(request=request, apply_changes=True) return backup_path - @staticmethod - def _operation_result(payload: OperationResultInput) -> OperationResult: - return _operation_result_helper(payload) - def _execute_target_operation( self, - *, - action: str, - key: str, - value: str | None, - target: str, + *args: Any, preview_only: bool, - resolved_scope_roots: Sequence[Path], secret_operation: bool, + **kwargs: Any, ) -> OperationResult: - operation_id = f"{action}-{uuid.uuid4().hex[:10]}" - value_masked = self._masked_value(secret_operation=secret_operation, value=value) + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + request = TargetOperationRequest( + target=request_data["target"], + key=request_data["key"], + value=request_data["value"], + action=request_data["action"], + scope_roots=request_data["scope_roots"], + ) + operation_id = f"{request.action}-{uuid.uuid4().hex[:10]}" + value_masked = self._masked_value(secret_operation=secret_operation, value=request.value) backup_path: str | None = None diff_preview = "" try: - before, diff_preview = self._preview_target_diff(target=target, key=key, value=value, action=action, resolved_scope_roots=resolved_scope_roots) + before, diff_preview = self._preview_target_diff(request) if not preview_only: - backup_path = self._apply_target_operation(target=target, key=key, value=value, action=action, before=before, resolved_scope_roots=resolved_scope_roots) - return self._operation_result( + backup_path = self._apply_target_operation(request, before=before) + return _operation_result_helper( OperationResultInput( operation_id=operation_id, - target=target, - action=action, + target=request.target, + action=request.action, success=True, backup_path=backup_path, preview_only=preview_only, @@ -659,11 +560,11 @@ def _execute_target_operation( ) ) except self._operation_error_types() as exc: - return self._operation_result( + return _operation_result_helper( OperationResultInput( operation_id=operation_id, - target=target, - action=action, + target=request.target, + action=request.action, success=False, backup_path=backup_path, preview_only=False, @@ -675,106 +576,47 @@ def _execute_target_operation( def _apply( self, - action: str, - *, - key: str, - value: str | None, - targets: List[str], + *args: Any, preview_only: bool = False, - scope_roots: List[str | Path] | None = None, + **kwargs: Any, ) -> List[OperationResult]: - validate_env_key(key) - if action == "set": - validate_env_value(value or "") + request_data = _normalize_target_operation_batch_helper(*args, **kwargs) + request = TargetOperationBatch( + action=request_data["action"], + key=request_data["key"], + value=request_data["value"], + targets=request_data["targets"], + scope_roots=request_data["scope_roots"], + ) + validate_env_key(request.key) + if request.action == "set": + validate_env_value(request.value or "") - secret_operation = looks_secret(key, value or "") - resolved_scope_roots = self._effective_scope_roots(scope_roots) + secret_operation = looks_secret(request.key, request.value or "") + resolved_scope_roots = self._effective_scope_roots(request.scope_roots) results: List[OperationResult] = [] - for target in targets: - result = self._execute_target_operation( - action=action, - key=key, - value=value, + for target in request.targets: + target_request = TargetOperationRequest( target=target, + key=request.key, + value=request.value, + action=request.action, + scope_roots=resolved_scope_roots, + ) + result = self._execute_target_operation( + target_request, preview_only=preview_only, - resolved_scope_roots=resolved_scope_roots, secret_operation=secret_operation, ) self.audit.log(audit_safe_result(result, redact=secret_operation)) results.append(result) return results - def preview_set( - self, - *, - key: str, - value: str, - targets: List[str], - scope_roots: List[str | Path] | None = None, - ) -> List[Dict[str, Any]]: - return [ - r.to_dict() - for r in self._apply("set", key=key, value=value, targets=targets, preview_only=True, scope_roots=scope_roots) - ] - def preview_remove( - self, - *, - key: str, - targets: List[str], - scope_roots: List[str | Path] | None = None, - ) -> List[Dict[str, Any]]: - return [ - r.to_dict() - for r in self._apply( - "remove", - key=key, - value=None, - targets=targets, - preview_only=True, - scope_roots=scope_roots, - ) - ] - def set_key( - self, - *, - key: str, - value: str, - targets: List[str], - scope_roots: List[str | Path] | None = None, - ) -> Dict[str, Any]: - results = self._apply("set", key=key, value=value, targets=targets, preview_only=False, scope_roots=scope_roots) - if len(results) == 1: - return results[0].to_dict() - return {"success": all(r.success for r in results), "results": [r.to_dict() for r in results]} - def remove_key( - self, - *, - key: str, - targets: List[str], - scope_roots: List[str | Path] | None = None, - ) -> Dict[str, Any]: - results = self._apply("remove", key=key, value=None, targets=targets, preview_only=False, scope_roots=scope_roots) - if len(results) == 1: - return results[0].to_dict() - return {"success": all(r.success for r in results), "results": [r.to_dict() for r in results]} - def export_records( - self, - *, - output: str, - include_raw_secrets: bool, - **list_kwargs: Any, - ) -> str: - rows = self.list_records(include_raw_secrets=include_raw_secrets, **list_kwargs) - return export_rows(rows, output=output) - def list_backups(self, *, target: str | None = None) -> List[str]: - if target: - return [str(p) for p in self.backup_mgr.list_backups(target)] - return [str(p) for p in self.backup_mgr.list_all_backups()] def _restore_dotenv_target(self, *, target: str, text: str, scope_roots: List[Path]) -> None: _restore_dotenv_target_helper( @@ -871,3 +713,33 @@ def restore_backup( self.audit.log(result) return result.to_dict() + +EnvInspectorService.which = which +EnvInspectorService.run = run +EnvInspectorService.get_powershell_profile_paths = staticmethod(_service_aliases.get_powershell_profile_paths) +EnvInspectorService._registry_write = _service_aliases.registry_write +EnvInspectorService._bridge_distros = _service_aliases.bridge_distros +EnvInspectorService.list_contexts = _service_aliases.list_contexts +EnvInspectorService._is_path_within = staticmethod(_is_path_within) +EnvInspectorService._validate_path_in_roots = staticmethod(_validate_path_in_roots) +EnvInspectorService._write_text_file = staticmethod(_write_text_file) +EnvInspectorService._write_scoped_text_file = staticmethod(_write_scoped_text_file) +EnvInspectorService._powershell_target_path_and_roots = _service_aliases.powershell_target_path_and_roots +EnvInspectorService._validated_powershell_restore_path = _service_aliases.validated_powershell_restore_path +EnvInspectorService._linux_etc_environment_path = classmethod(_service_aliases.linux_etc_environment_path) +EnvInspectorService._apply_row_filters = staticmethod(_apply_row_filters_helper) +EnvInspectorService._diff = staticmethod(_diff_text_helper) +EnvInspectorService.available_targets = _service_aliases.available_targets +EnvInspectorService._powershell_target_for_path = staticmethod(_powershell_target_for_path_helper) +EnvInspectorService._record_target = staticmethod(_record_target_helper) +EnvInspectorService._masked_value = staticmethod(_masked_value_helper) +EnvInspectorService._operation_error_types = staticmethod(_operation_error_types_helper) +EnvInspectorService._validate_wsl_distro_name = staticmethod(_validate_wsl_distro_name_helper) +EnvInspectorService._validate_wsl_dotenv_path = staticmethod(_service_aliases.validate_wsl_dotenv_path) +EnvInspectorService.list_records_raw = _service_aliases.list_records_raw +EnvInspectorService.preview_set = _service_aliases.preview_set +EnvInspectorService.preview_remove = _service_aliases.preview_remove +EnvInspectorService.set_key = _service_aliases.set_key +EnvInspectorService.remove_key = _service_aliases.remove_key +EnvInspectorService.export_records = _service_aliases.export_records +EnvInspectorService.list_backups = _service_aliases.list_backups diff --git a/env_inspector_core/service_aliases.py b/env_inspector_core/service_aliases.py new file mode 100644 index 0000000..4f3f7bc --- /dev/null +++ b/env_inspector_core/service_aliases.py @@ -0,0 +1,188 @@ +from __future__ import absolute_import, division + +from pathlib import Path +from typing import Any, List + +from .models import EnvRecord +from .rendering import export_rows +from .service_paths import ( + get_powershell_profile_paths as _get_powershell_profile_paths, + linux_etc_environment_path as _linux_etc_environment_path, + powershell_target_path_and_roots as _powershell_target_path_and_roots, + validated_powershell_restore_path as _validated_powershell_restore_path, +) +from .service_privileged import write_linux_etc_environment_with_privilege as _write_linux_etc_environment_with_privilege_helper +from .service_listing import available_targets as _available_targets_helper +from .service_wsl import validate_wsl_dotenv_path as _validate_wsl_dotenv_path_helper + + +from .providers import WindowsRegistryProvider +from .service_ops import normalize_target_operation_request as _normalize_target_operation_request_helper +import json + + +def registry_write(self, *args: Any, apply_changes: bool, **kwargs: Any): + request_data = _normalize_target_operation_request_helper(*args, **kwargs) + target = request_data["target"] + key = request_data["key"] + value = request_data["value"] + action = request_data["action"] + if self.win_provider is None: + raise RuntimeError("Windows registry provider unavailable.") + scope = WindowsRegistryProvider.USER_SCOPE if target == "windows:user" else WindowsRegistryProvider.MACHINE_SCOPE + current = self.win_provider.list_scope(scope) + before = json.dumps(current, indent=2, sort_keys=True) + if action == "set" and value is not None: + if apply_changes: + self.win_provider.set_scope_value(scope, key, value) + current[key] = value + elif action == "remove": + if apply_changes: + self.win_provider.remove_scope_value(scope, key) + current.pop(key, None) + after = json.dumps(current, indent=2, sort_keys=True) + requires_priv = target == "windows:machine" + return before, after, None, requires_priv, None + + + +def bridge_distros(self) -> List[str]: + if not self.wsl.available(): + return [] + distros = self.wsl.list_distros_for_ui() + if self.runtime_context == "linux" and self.current_wsl_distro: + current = self.current_wsl_distro.lower() + distros = [d for d in distros if d.lower() != current] + return distros + + +def list_contexts(self) -> List[str]: + contexts = [self.runtime_context] + if self.wsl.available(): + for distro in self._bridge_distros(): + contexts.append(f"wsl:{distro}") + return contexts + + + +def get_powershell_profile_paths() -> List[Path]: + return _get_powershell_profile_paths() + + +def powershell_target_path_and_roots(self, target: str): + return _powershell_target_path_and_roots( + target, + profile_resolver=self._powershell_profile_path, + current_user_target="powershell:current_user", + all_users_target="powershell:all_users", + ) + + +def validated_powershell_restore_path(self, target: str) -> Path: + return _validated_powershell_restore_path( + target, + profile_resolver=self._powershell_profile_path, + current_user_target="powershell:current_user", + all_users_target="powershell:all_users", + ) + + +def linux_etc_environment_path(cls) -> Path: + return _linux_etc_environment_path(cls._LINUX_ETC_ENV_PATH) + + +def write_linux_etc_environment_with_privilege(cls, text: str) -> None: + _write_linux_etc_environment_with_privilege_helper( + fixed_path=cls._LINUX_ETC_ENV_PATH, + expected_path=cls._LINUX_ETC_ENV_PATH, + text=text, + write_text_file=lambda path, payload: cls._write_text_file(path, payload, ensure_parent=False), + which_fn=cls.which, + run_fn=cls.run, + ) + + +def available_targets(self, records: List[EnvRecord], context: str | None = None) -> List[str]: + return _available_targets_helper( + records, + context=context, + win_provider_present=self.win_provider is not None, + ) + + +def list_records_raw(self, **kwargs: Any) -> List[EnvRecord]: + payload = self.list_records(include_raw_secrets=True, **kwargs) + return [EnvRecord(**item) for item in payload] + + +def preview_set(self, *, key: str, value: str, targets: List[str], scope_roots=None) -> List[dict]: + return [ + r.to_dict() + for r in self._apply( + action="set", + key=key, + value=value, + targets=targets, + scope_roots=scope_roots, + preview_only=True, + ) + ] + + +def preview_remove(self, *, key: str, targets: List[str], scope_roots=None) -> List[dict]: + return [ + r.to_dict() + for r in self._apply( + action="remove", + key=key, + value=None, + targets=targets, + scope_roots=scope_roots, + preview_only=True, + ) + ] + + +def _results_payload(results): + if len(results) == 1: + return results[0].to_dict() + return {"success": all(r.success for r in results), "results": [r.to_dict() for r in results]} + + +def set_key(self, *, key: str, value: str, targets: List[str], scope_roots=None): + results = self._apply( + action="set", + key=key, + value=value, + targets=targets, + scope_roots=scope_roots, + preview_only=False, + ) + return _results_payload(results) + + +def remove_key(self, *, key: str, targets: List[str], scope_roots=None): + results = self._apply( + action="remove", + key=key, + value=None, + targets=targets, + scope_roots=scope_roots, + preview_only=False, + ) + return _results_payload(results) + + +def export_records(self, *, output: str, include_raw_secrets: bool, **list_kwargs: Any) -> str: + rows = self.list_records(include_raw_secrets=include_raw_secrets, **list_kwargs) + return export_rows(rows, output=output) + + +def list_backups(self, *, target: str | None = None) -> List[str]: + if target: + return [str(p) for p in self.backup_mgr.list_backups(target)] + return [str(p) for p in self.backup_mgr.list_all_backups()] + + +def validate_wsl_dotenv_path(raw: str) -> str: + return _validate_wsl_dotenv_path_helper(raw, path_error="Unsupported WSL dotenv target path") diff --git a/env_inspector_core/service_listing.py b/env_inspector_core/service_listing.py index e43fe82..8a0b27b 100644 --- a/env_inspector_core/service_listing.py +++ b/env_inspector_core/service_listing.py @@ -1,181 +1,208 @@ -from __future__ import absolute_import - -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set - -from .constants import ( - SOURCE_DOTENV, - SOURCE_LINUX_BASHRC, - SOURCE_LINUX_ETC_ENV, - SOURCE_POWERSHELL_PROFILE, - SOURCE_WSL_BASHRC, - SOURCE_WSL_DOTENV, - SOURCE_WSL_ETC_ENV, -) -from .models import EnvRecord -from .secrets import mask_value - -TARGET_LINUX_BASHRC = "linux:bashrc" -TARGET_LINUX_ETC_ENV = "linux:etc_environment" -TARGET_POWERSHELL_CURRENT_USER = "powershell:current_user" -TARGET_POWERSHELL_ALL_USERS = "powershell:all_users" -TARGET_WINDOWS_USER = "windows:user" -TARGET_WINDOWS_MACHINE = "windows:machine" - - +from __future__ import absolute_import + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Set + +from .constants import ( + SOURCE_DOTENV, + SOURCE_LINUX_BASHRC, + SOURCE_LINUX_ETC_ENV, + SOURCE_POWERSHELL_PROFILE, + SOURCE_WSL_BASHRC, + SOURCE_WSL_DOTENV, + SOURCE_WSL_ETC_ENV, +) +from .models import EnvRecord +from .secrets import mask_value + +TARGET_LINUX_BASHRC = "linux:bashrc" +TARGET_LINUX_ETC_ENV = "linux:etc_environment" +TARGET_POWERSHELL_CURRENT_USER = "powershell:current_user" +TARGET_POWERSHELL_ALL_USERS = "powershell:all_users" +TARGET_WINDOWS_USER = "windows:user" +TARGET_WINDOWS_MACHINE = "windows:machine" + + +@dataclass(frozen=True) +class HostCollectionRequest: + runtime_context: str + root_path: Path + scan_depth: int + win_provider: Any + powershell_profile_paths: List[Path] + + @dataclass(frozen=True) -class HostCollectionRequest: - runtime_context: str - root_path: Path - scan_depth: int - win_provider: Any - powershell_profile_paths: List[Path] +class HostRowCollectors: + collect_process_records_fn: Callable[..., List[EnvRecord]] + collect_dotenv_records_fn: Callable[..., List[EnvRecord]] + build_registry_records_fn: Callable[[Any], List[EnvRecord]] + collect_powershell_profile_records_fn: Callable[[List[Path]], List[EnvRecord]] + collect_linux_records_fn: Callable[..., List[EnvRecord]] @dataclass(frozen=True) -class HostRowCollectors: - collect_process_records_fn: Callable[..., List[EnvRecord]] - collect_dotenv_records_fn: Callable[..., List[EnvRecord]] - build_registry_records_fn: Callable[[Any], List[EnvRecord]] - collect_powershell_profile_records_fn: Callable[[List[Path]], List[EnvRecord]] - collect_linux_records_fn: Callable[..., List[EnvRecord]] +class _WslDotenvRequest: + distro: str | None + wsl_path: str | None + scan_depth: int + + +def collect_host_rows( + *, + request: HostCollectionRequest, + collectors: HostRowCollectors, +) -> List[EnvRecord]: + rows: List[EnvRecord] = [] + rows.extend(collectors.collect_process_records_fn(context=request.runtime_context)) + rows.extend( + collectors.collect_dotenv_records_fn( + request.root_path, + max_depth=request.scan_depth, + context=request.runtime_context, + ) + ) + + if request.win_provider is not None: + try: + registry_rows = collectors.build_registry_records_fn(request.win_provider) + except (OSError, RuntimeError, ValueError): + registry_rows = [] + rows.extend(registry_rows) + + if request.runtime_context == "windows": + rows.extend(collectors.collect_powershell_profile_records_fn(request.powershell_profile_paths)) + else: + rows.extend(collectors.collect_linux_records_fn(context=request.runtime_context)) + + return rows + + +def collect_wsl_rows(*args: Any, **kwargs: Any) -> List[EnvRecord]: + if args: + raise TypeError("collect_wsl_rows accepts keyword arguments only.") + + runtime_context = kwargs.pop("runtime_context") + current_wsl_distro = kwargs.pop("current_wsl_distro") + wsl = kwargs.pop("wsl") + scan_depth = kwargs.pop("scan_depth") + distro = kwargs.pop("distro") + wsl_path = kwargs.pop("wsl_path") + collect_wsl_records_fn = kwargs.pop("collect_wsl_records_fn") + collect_wsl_dotenv_records_fn = kwargs.pop("collect_wsl_dotenv_records_fn") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + if not wsl.available(): + return [] -def collect_host_rows( - *, - request: HostCollectionRequest, - collectors: HostRowCollectors, -) -> List[EnvRecord]: - rows: List[EnvRecord] = [] - rows.extend(collectors.collect_process_records_fn(context=request.runtime_context)) + rows = _bridge_rows( + runtime_context=runtime_context, + current_wsl_distro=current_wsl_distro, + wsl=wsl, + collect_wsl_records_fn=collect_wsl_records_fn, + ) rows.extend( - collectors.collect_dotenv_records_fn( - request.root_path, - max_depth=request.scan_depth, - context=request.runtime_context, + _wsl_dotenv_rows( + request=_WslDotenvRequest(distro=distro, wsl_path=wsl_path, scan_depth=scan_depth), + wsl=wsl, + collect_wsl_dotenv_records_fn=collect_wsl_dotenv_records_fn, ) ) - - if request.win_provider is not None: - try: - registry_rows = collectors.build_registry_records_fn(request.win_provider) - except (OSError, RuntimeError, ValueError): - registry_rows = [] - rows.extend(registry_rows) - - if request.runtime_context == "windows": - rows.extend(collectors.collect_powershell_profile_records_fn(request.powershell_profile_paths)) - else: - rows.extend(collectors.collect_linux_records_fn(context=request.runtime_context)) - return rows -def collect_wsl_rows( - *, - runtime_context: str, - current_wsl_distro: Optional[str], - wsl: Any, - scan_depth: int, - distro: Optional[str], - wsl_path: Optional[str], - collect_wsl_records_fn: Callable[..., List[EnvRecord]], - collect_wsl_dotenv_records_fn: Callable[..., List[EnvRecord]], -) -> List[EnvRecord]: - rows: List[EnvRecord] = [] - if not wsl.available(): - return rows - +def _bridge_rows(*, runtime_context: str, current_wsl_distro: str | None, wsl: Any, collect_wsl_records_fn) -> List[EnvRecord]: try: exclude_distros: Optional[Set[str]] = None if runtime_context == "linux" and current_wsl_distro: exclude_distros = {current_wsl_distro} - bridge_rows = collect_wsl_records_fn(wsl, include_etc=True, exclude_distros=exclude_distros) + return collect_wsl_records_fn(wsl, include_etc=True, exclude_distros=exclude_distros) except (OSError, RuntimeError, ValueError): - bridge_rows = [] - rows.extend(bridge_rows) - - if distro and wsl_path: - try: - dotenv_rows = collect_wsl_dotenv_records_fn( - wsl, - distro=distro, - root_path=wsl_path, - max_depth=scan_depth, - ) - except (OSError, RuntimeError, ValueError): - dotenv_rows = [] - rows.extend(dotenv_rows) - - return rows - - -def apply_row_filters( - rows: List[EnvRecord], - *, - source: Optional[List[str]], - context: Optional[str], -) -> List[EnvRecord]: - if source: - source_set = set(source) - rows = [record for record in rows if record.source_type in source_set] - if context: - rows = [record for record in rows if record.context == context] - return rows - - -def powershell_target_for_path(source_path: str) -> str: - return TARGET_POWERSHELL_ALL_USERS if "Program Files" in source_path else TARGET_POWERSHELL_CURRENT_USER + return [] -def record_target(record: EnvRecord) -> Optional[str]: - static_targets = { - SOURCE_LINUX_BASHRC: TARGET_LINUX_BASHRC, - SOURCE_LINUX_ETC_ENV: TARGET_LINUX_ETC_ENV, - } - dynamic_targets: Dict[str, Callable[[EnvRecord], str]] = { - SOURCE_DOTENV: lambda rec: f"dotenv:{rec.source_path}", - SOURCE_WSL_DOTENV: lambda rec: f"wsl_dotenv:{rec.source_path}", - SOURCE_WSL_BASHRC: lambda rec: f"wsl:{rec.source_id}:bashrc", - SOURCE_WSL_ETC_ENV: lambda rec: f"wsl:{rec.source_id}:etc_environment", - SOURCE_POWERSHELL_PROFILE: lambda rec: powershell_target_for_path(rec.source_path), - } - - static_target = static_targets.get(record.source_type) - if static_target is not None: - return static_target - builder = dynamic_targets.get(record.source_type) - return builder(record) if builder is not None else None - - -def available_targets( - records: List[EnvRecord], - *, - context: Optional[str], - win_provider_present: bool, -) -> List[str]: - targets: Set[str] = set() - for record in records: - if context and record.context != context: - continue - mapped_target = record_target(record) - if mapped_target: - targets.add(mapped_target) - if win_provider_present: - targets.add(TARGET_WINDOWS_USER) - targets.add(TARGET_WINDOWS_MACHINE) - if context == "linux": - targets.add(TARGET_LINUX_BASHRC) - targets.add(TARGET_LINUX_ETC_ENV) - return sorted(targets) - - -def rows_to_payload(rows: List[EnvRecord], *, include_raw_secrets: bool) -> List[Dict[str, Any]]: - payload: List[Dict[str, Any]] = [] - for record in rows: - item = record.to_dict(include_value=True) - if bool(item.get("is_secret")) and not include_raw_secrets: - item["value"] = mask_value(record.value) - payload.append(item) - return payload +def _wsl_dotenv_rows(*, request: _WslDotenvRequest, wsl: Any, collect_wsl_dotenv_records_fn) -> List[EnvRecord]: + if not (request.distro and request.wsl_path): + return [] + try: + return collect_wsl_dotenv_records_fn( + wsl, + distro=request.distro, + root_path=request.wsl_path, + max_depth=request.scan_depth, + ) + except (OSError, RuntimeError, ValueError): + return [] + + +def apply_row_filters( + rows: List[EnvRecord], + *, + source: Optional[List[str]], + context: Optional[str], +) -> List[EnvRecord]: + if source: + source_set = set(source) + rows = [record for record in rows if record.source_type in source_set] + if context: + rows = [record for record in rows if record.context == context] + return rows + + +def powershell_target_for_path(source_path: str) -> str: + return TARGET_POWERSHELL_ALL_USERS if "Program Files" in source_path else TARGET_POWERSHELL_CURRENT_USER + + +def record_target(record: EnvRecord) -> Optional[str]: + static_targets = { + SOURCE_LINUX_BASHRC: TARGET_LINUX_BASHRC, + SOURCE_LINUX_ETC_ENV: TARGET_LINUX_ETC_ENV, + } + dynamic_targets: Dict[str, Callable[[EnvRecord], str]] = { + SOURCE_DOTENV: lambda rec: f"dotenv:{rec.source_path}", + SOURCE_WSL_DOTENV: lambda rec: f"wsl_dotenv:{rec.source_path}", + SOURCE_WSL_BASHRC: lambda rec: f"wsl:{rec.source_id}:bashrc", + SOURCE_WSL_ETC_ENV: lambda rec: f"wsl:{rec.source_id}:etc_environment", + SOURCE_POWERSHELL_PROFILE: lambda rec: powershell_target_for_path(rec.source_path), + } + + static_target = static_targets.get(record.source_type) + if static_target is not None: + return static_target + builder = dynamic_targets.get(record.source_type) + return builder(record) if builder is not None else None + + +def available_targets( + records: List[EnvRecord], + *, + context: Optional[str], + win_provider_present: bool, +) -> List[str]: + targets: Set[str] = set() + for record in records: + if context and record.context != context: + continue + mapped_target = record_target(record) + if mapped_target: + targets.add(mapped_target) + if win_provider_present: + targets.add(TARGET_WINDOWS_USER) + targets.add(TARGET_WINDOWS_MACHINE) + if context == "linux": + targets.add(TARGET_LINUX_BASHRC) + targets.add(TARGET_LINUX_ETC_ENV) + return sorted(targets) + + +def rows_to_payload(rows: List[EnvRecord], *, include_raw_secrets: bool) -> List[Dict[str, Any]]: + payload: List[Dict[str, Any]] = [] + for record in rows: + item = record.to_dict(include_value=True) + if bool(item.get("is_secret")) and not include_raw_secrets: + item["value"] = mask_value(record.value) + payload.append(item) + return payload diff --git a/env_inspector_core/service_ops.py b/env_inspector_core/service_ops.py index 9aa858f..7e9fe13 100644 --- a/env_inspector_core/service_ops.py +++ b/env_inspector_core/service_ops.py @@ -1,84 +1,69 @@ -from __future__ import absolute_import, division - +from __future__ import absolute_import, division + from dataclasses import dataclass import difflib from typing import Tuple, Type from .models import OperationResult from .secrets import mask_value +from . import service_ops_request as _service_ops_request - -@dataclass(frozen=True) -class OperationResultInput: - operation_id: str - target: str - action: str - success: bool - backup_path: str | None - preview_only: bool - diff_preview: str - error_message: str | None - value_masked: str | None - - -def diff_text(before: str, after: str, target: str) -> str: - diff = difflib.unified_diff( - before.splitlines(), - after.splitlines(), - fromfile=f"{target} (before)", - tofile=f"{target} (after)", - lineterm="", - ) - return "\n".join(diff) - - -def masked_value(*, secret_operation: bool, value: str | None) -> str | None: - if not secret_operation or value is None: - return None - return mask_value(value) - - -def make_operation_result( - *, - operation_id: str, - target: str, - action: str, - success: bool, - backup_path: str | None, - diff_preview: str, - error_message: str | None, - value_masked: str | None, -) -> OperationResult: - return OperationResult( - operation_id=operation_id, - target=target, - action=action, - success=success, - backup_path=backup_path, - diff_preview=diff_preview, - error_message=error_message, - value_masked=value_masked, - ) - - -def operation_result(payload: OperationResultInput) -> OperationResult: - return make_operation_result( - operation_id=payload.operation_id, - target=payload.target, - action=payload.action, - success=payload.success, - backup_path=(None if payload.preview_only and payload.success else payload.backup_path), - diff_preview=payload.diff_preview, - error_message=payload.error_message, - value_masked=payload.value_masked, - ) - - -def operation_error_types() -> Tuple[Type[BaseException], ...]: - return ( - RuntimeError, - ValueError, - TypeError, - OSError, - PermissionError, - ) +normalize_target_operation_batch = _service_ops_request.normalize_target_operation_batch +normalize_target_operation_request = _service_ops_request.normalize_target_operation_request + + +@dataclass(frozen=True) +class OperationResultInput: + operation_id: str + target: str + action: str + success: bool + backup_path: str | None + preview_only: bool + diff_preview: str + error_message: str | None + value_masked: str | None + + +def diff_text(before: str, after: str, target: str) -> str: + diff = difflib.unified_diff( + before.splitlines(), + after.splitlines(), + fromfile=f"{target} (before)", + tofile=f"{target} (after)", + lineterm="", + ) + return "\n".join(diff) + + +def masked_value(*, secret_operation: bool, value: str | None) -> str | None: + if not secret_operation or value is None: + return None + return mask_value(value) + + +def make_operation_result(payload: OperationResultInput) -> OperationResult: + return OperationResult( + operation_id=payload.operation_id, + target=payload.target, + action=payload.action, + success=payload.success, + backup_path=(None if payload.preview_only and payload.success else payload.backup_path), + diff_preview=payload.diff_preview, + error_message=payload.error_message, + value_masked=payload.value_masked, + ) + + +def operation_result(payload: OperationResultInput) -> OperationResult: + return make_operation_result(payload) + + +def operation_error_types() -> Tuple[Type[BaseException], ...]: + return ( + RuntimeError, + ValueError, + TypeError, + OSError, + PermissionError, + ) diff --git a/env_inspector_core/service_ops_request.py b/env_inspector_core/service_ops_request.py new file mode 100644 index 0000000..e138188 --- /dev/null +++ b/env_inspector_core/service_ops_request.py @@ -0,0 +1,127 @@ +from __future__ import absolute_import, division + +from typing import Any, Dict, Tuple + + +def _raise_mixed_request_usage() -> None: + raise TypeError("Pass either a request object or legacy arguments, not both.") + + +def _extract_request_object( + *, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + required_attributes: Tuple[str, ...], +) -> Any | None: + if "request" in kwargs: + request = kwargs.pop("request") + if kwargs or args: + _raise_mixed_request_usage() + return request + + if len(args) != 1 or kwargs: + return None + + request = args[0] + if all(hasattr(request, attribute) for attribute in required_attributes): + return request + return None + + +def _target_operation_payload(request: Any) -> Dict[str, Any]: + return { + "target": request.target, + "key": request.key, + "value": request.value, + "action": request.action, + "scope_roots": list(request.scope_roots), + } + + +def _target_operation_batch_payload(request: Any) -> Dict[str, Any]: + return { + "action": request.action, + "key": request.key, + "value": request.value, + "targets": list(request.targets), + "scope_roots": None if request.scope_roots is None else list(request.scope_roots), + } + + +def _coerce_string(value: Any) -> str: + return str(value) + + +def _coerce_optional_string(value: Any) -> str | None: + return None if value is None else _coerce_string(value) + + +def _require_values(message: str, **values: Any) -> None: + if any(value is None for value in values.values()): + raise TypeError(message) + + +def _resolve_operation_inputs( + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + field_names: Tuple[str, ...], +) -> Tuple[Any, ...]: + if args and isinstance(args[0], str): + return tuple( + args[index] if len(args) > index else kwargs.pop(name, None) + for index, name in enumerate(field_names) + ) + return tuple(kwargs.pop(name, None) for name in field_names) + + +def normalize_target_operation_request(*args: Any, **kwargs: Any) -> Dict[str, Any]: + request = _extract_request_object( + args=args, + kwargs=kwargs, + required_attributes=("target", "key", "value", "action", "scope_roots"), + ) + if request is not None: + return _target_operation_payload(request) + target, key, value, action, scope_roots = _resolve_operation_inputs( + args, + kwargs, + ("target", "key", "value", "action", "scope_roots"), + ) + + if kwargs: + raise TypeError("Unexpected keyword arguments for target operation request.") + _require_values("Target, key, and action are required.", target=target, key=key, action=action) + + return { + "target": _coerce_string(target), + "key": _coerce_string(key), + "value": _coerce_optional_string(value), + "action": _coerce_string(action), + "scope_roots": list(scope_roots or []), + } + +def normalize_target_operation_batch(*args: Any, **kwargs: Any) -> Dict[str, Any]: + request = _extract_request_object( + args=args, + kwargs=kwargs, + required_attributes=("action", "key", "value", "targets", "scope_roots"), + ) + if request is not None: + return _target_operation_batch_payload(request) + action, key, value, targets, scope_roots = _resolve_operation_inputs( + args, + kwargs, + ("action", "key", "value", "targets", "scope_roots"), + ) + + if kwargs: + raise TypeError("Unexpected keyword arguments for target operation batch.") + _require_values("Action, key, and targets are required.", action=action, key=key, targets=targets) + + return { + "action": _coerce_string(action), + "key": _coerce_string(key), + "value": _coerce_optional_string(value), + "targets": list(targets), + "scope_roots": None if scope_roots is None else list(scope_roots), + } diff --git a/env_inspector_core/service_privileged.py b/env_inspector_core/service_privileged.py index 49b61e8..c536bff 100644 --- a/env_inspector_core/service_privileged.py +++ b/env_inspector_core/service_privileged.py @@ -1,61 +1,82 @@ -from __future__ import absolute_import, division - -from pathlib import Path -from shutil import which -from subprocess import PIPE, CompletedProcess, run # nosec B404 -from typing import Callable, Optional - - -def _try_direct_write(path: Path, text: str, write_text_file: Callable[[Path, str], None]) -> bool: - try: - write_text_file(path, text) - return True - except OSError: - return False - - +from __future__ import absolute_import, division + +from pathlib import Path +from shutil import which +from subprocess import PIPE, CompletedProcess, run # nosec B404 +from typing import Callable, Optional + + +def _try_direct_write(path: Path, text: str, write_text_file: Callable[[Path, str], None]) -> bool: + try: + write_text_file(path, text) + return True + except OSError: + return False + + def _run_sudo_tee( - allowed_sudo_path: str, - expected_path: str, - text: str, - run_fn: Callable[..., CompletedProcess], -) -> CompletedProcess: - return run_fn( # nosec B603 - [allowed_sudo_path, "-n", "tee", expected_path], - input=text, - text=True, - stdout=PIPE, - stderr=PIPE, + allowed_sudo_path: str, + expected_path: str, + text: str, + run_fn: Callable[..., CompletedProcess], +) -> CompletedProcess: + return run_fn( # nosec B603 + [allowed_sudo_path, "-n", "tee", expected_path], + input=text, + text=True, + stdout=PIPE, + stderr=PIPE, check=False, ) -def write_linux_etc_environment_with_privilege( +def _resolve_allowed_sudo(which_fn: Callable[[str], Optional[str]]) -> str: + sudo_path = which_fn("sudo") + if sudo_path in {"/usr/bin/sudo", "/bin/sudo"}: + return sudo_path + raise RuntimeError("sudo is not available for /etc/environment fallback.") + + +def _write_with_sudo( *, - fixed_path: str, expected_path: str, text: str, - write_text_file: Callable[[Path, str], None], - which_fn: Callable[[str], Optional[str]] = which, - run_fn: Callable[..., CompletedProcess] = run, + which_fn: Callable[[str], Optional[str]], + run_fn: Callable[..., CompletedProcess], ) -> None: - if fixed_path != expected_path: - raise RuntimeError(f"Unexpected /etc/environment resolution: {fixed_path}") - path = Path(expected_path) - if _try_direct_write(path, text, write_text_file): - return - - sudo_path = which_fn("sudo") - allowed_sudo_path = sudo_path if sudo_path in {"/usr/bin/sudo", "/bin/sudo"} else None - if not allowed_sudo_path: - raise RuntimeError("sudo is not available for /etc/environment fallback.") - proc = _run_sudo_tee(allowed_sudo_path, expected_path, text, run_fn) + proc = _run_sudo_tee(_resolve_allowed_sudo(which_fn), expected_path, text, run_fn) if proc.returncode == 0: return - err = (proc.stderr or "").strip() raise RuntimeError( "Failed to write /etc/environment using direct write and sudo fallback. " "Run with elevated privileges or configure passwordless sudo for this command." + (f" Details: {err}" if err else "") ) + + +def write_linux_etc_environment_with_privilege(*args, **kwargs) -> None: + if args: + raise TypeError("write_linux_etc_environment_with_privilege accepts keyword arguments only.") + + fixed_path = kwargs.pop("fixed_path") + expected_path = kwargs.pop("expected_path") + text = kwargs.pop("text") + write_text_file = kwargs.pop("write_text_file") + which_fn = kwargs.pop("which_fn", which) + run_fn = kwargs.pop("run_fn", run) + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + + if fixed_path != expected_path: + raise RuntimeError(f"Unexpected /etc/environment resolution: {fixed_path}") + path = Path(expected_path) + if _try_direct_write(path, text, write_text_file): + return + _write_with_sudo( + expected_path=expected_path, + text=text, + which_fn=which_fn, + run_fn=run_fn, + ) diff --git a/env_inspector_core/service_restore.py b/env_inspector_core/service_restore.py index 801b0e0..998c167 100644 --- a/env_inspector_core/service_restore.py +++ b/env_inspector_core/service_restore.py @@ -1,131 +1,189 @@ -from __future__ import absolute_import, division - +from __future__ import absolute_import, division + import json from pathlib import Path -from typing import Any, Callable, List, Tuple, cast - - -def restore_dotenv_target( - *, - target: str, - text: str, - scope_roots: List[Path], - parse_scoped_dotenv_target_fn: Callable[..., Any], - write_scoped_text_file_fn: Callable[..., Path], -) -> None: - scoped = parse_scoped_dotenv_target_fn(target, roots=scope_roots) - write_scoped_text_file_fn( - candidate_path=scoped.path, - allowed_roots=scoped.roots, - text=text, - label="restore dotenv path", - ) - - -def restore_linux_target( - *, - target: str, - text: str, - write_linux_etc_environment_with_privilege_fn: Callable[[str], None], - bashrc_target: str = "linux:bashrc", - etc_target: str = "linux:etc_environment", -) -> None: + + +def restore_dotenv_target(*args, **kwargs) -> None: + if args: + raise TypeError("restore_dotenv_target accepts keyword arguments only.") + + target = kwargs.pop("target") + text = kwargs.pop("text") + scope_roots = kwargs.pop("scope_roots") + parse_scoped_dotenv_target_fn = kwargs.pop("parse_scoped_dotenv_target_fn") + write_scoped_text_file_fn = kwargs.pop("write_scoped_text_file_fn") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + + scoped = parse_scoped_dotenv_target_fn(target, roots=scope_roots) + write_scoped_text_file_fn( + candidate_path=scoped.path, + allowed_roots=scoped.roots, + text=text, + label="restore dotenv path", + ) + + +def restore_linux_target(*args, **kwargs) -> None: + if args: + raise TypeError("restore_linux_target accepts keyword arguments only.") + + target = kwargs.pop("target") + text = kwargs.pop("text") + write_linux_etc_environment_with_privilege_fn = kwargs.pop("write_linux_etc_environment_with_privilege_fn") + bashrc_target = kwargs.pop("bashrc_target", "linux:bashrc") + etc_target = kwargs.pop("etc_target", "linux:etc_environment") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + if target == bashrc_target: path_out = Path(Path.home(), ".bashrc") - bashrc_parent = cast(Path, path_out.parent) + bashrc_parent = Path(path_out.parent) bashrc_parent.mkdir(parents=True, exist_ok=True) path_out.write_text(text, encoding="utf-8") return - if target == etc_target: - write_linux_etc_environment_with_privilege_fn(text) - return - raise RuntimeError(f"Unsupported Linux restore target: {target}") - - -def restore_wsl_target( - *, - target: str, - text: str, - wsl: Any, - parse_wsl_dotenv_target_fn: Callable[[str], Tuple[str, str]], - validate_wsl_distro_name_fn: Callable[[str], str], - linux_etc_env_path: str, - wsl_dotenv_prefix: str = "wsl_dotenv:", -) -> None: - if target.startswith(wsl_dotenv_prefix): - distro, path = parse_wsl_dotenv_target_fn(target) - wsl.write_file(distro, path, text) - return - if target.startswith("wsl:") and target.endswith(":bashrc"): - distro = validate_wsl_distro_name_fn(target.split(":", 2)[1]) - wsl.write_file(distro, "~/.bashrc", text) - return - if target.startswith("wsl:") and target.endswith(":etc_environment"): - distro = validate_wsl_distro_name_fn(target.split(":", 2)[1]) - wsl.write_file_with_privilege(distro, linux_etc_env_path, text) - return - raise RuntimeError(f"Unsupported WSL restore target: {target}") - - -def restore_powershell_target( - *, - target: str, - text: str, - validated_powershell_restore_path_fn: Callable[[str], Path], - write_text_file_fn: Callable[..., None], -) -> None: - safe_profile = validated_powershell_restore_path_fn(target) - write_text_file_fn(safe_profile, text) - + if target == etc_target: + write_linux_etc_environment_with_privilege_fn(text) + return + raise RuntimeError(f"Unsupported Linux restore target: {target}") + + +def restore_wsl_target(*args, **kwargs) -> None: + if args: + raise TypeError("restore_wsl_target accepts keyword arguments only.") + + target = kwargs.pop("target") + text = kwargs.pop("text") + wsl = kwargs.pop("wsl") + parse_wsl_dotenv_target_fn = kwargs.pop("parse_wsl_dotenv_target_fn") + validate_wsl_distro_name_fn = kwargs.pop("validate_wsl_distro_name_fn") + linux_etc_env_path = kwargs.pop("linux_etc_env_path") + wsl_dotenv_prefix = kwargs.pop("wsl_dotenv_prefix", "wsl_dotenv:") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + + if target.startswith(wsl_dotenv_prefix): + distro, path = parse_wsl_dotenv_target_fn(target) + wsl.write_file(distro, path, text) + return + if target.startswith("wsl:") and target.endswith(":bashrc"): + distro = validate_wsl_distro_name_fn(target.split(":", 2)[1]) + wsl.write_file(distro, "~/.bashrc", text) + return + if target.startswith("wsl:") and target.endswith(":etc_environment"): + distro = validate_wsl_distro_name_fn(target.split(":", 2)[1]) + wsl.write_file_with_privilege(distro, linux_etc_env_path, text) + return + raise RuntimeError(f"Unsupported WSL restore target: {target}") + + +def restore_powershell_target(*args, **kwargs) -> None: + if args: + raise TypeError("restore_powershell_target accepts keyword arguments only.") + + target = kwargs.pop("target") + text = kwargs.pop("text") + validated_powershell_restore_path_fn = kwargs.pop("validated_powershell_restore_path_fn") + write_text_file_fn = kwargs.pop("write_text_file_fn") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + + safe_profile = validated_powershell_restore_path_fn(target) + write_text_file_fn(safe_profile, text) + + +def restore_windows_registry_target(*args, **kwargs) -> None: + if args: + raise TypeError("restore_windows_registry_target accepts keyword arguments only.") + + target = kwargs.pop("target") + text = kwargs.pop("text") + win_provider = kwargs.pop("win_provider") + windows_registry_provider_cls = kwargs.pop("windows_registry_provider_cls") + user_target = kwargs.pop("user_target", "windows:user") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + + if win_provider is None: + raise RuntimeError("Windows provider unavailable for registry restore") + data = json.loads(text) + scope = ( + windows_registry_provider_cls.USER_SCOPE + if target == user_target + else windows_registry_provider_cls.MACHINE_SCOPE + ) + current = win_provider.list_scope(scope) + for key in tuple(current): + if key not in data: + win_provider.remove_scope_value(scope, key) + for key, value in data.items(): + win_provider.set_scope_value(scope, key, str(value)) + + +def restore_target(*args, **kwargs) -> None: + if args: + raise TypeError("restore_target accepts keyword arguments only.") + + target = kwargs.pop("target") + text = kwargs.pop("text") + scope_roots = kwargs.pop("scope_roots") + restore_dotenv_target_fn = kwargs.pop("restore_dotenv_target_fn") + restore_linux_target_fn = kwargs.pop("restore_linux_target_fn") + restore_wsl_target_fn = kwargs.pop("restore_wsl_target_fn") + restore_powershell_target_fn = kwargs.pop("restore_powershell_target_fn") + restore_windows_registry_target_fn = kwargs.pop("restore_windows_registry_target_fn") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") -def restore_windows_registry_target( - *, - target: str, - text: str, - win_provider: Any, - windows_registry_provider_cls: Any, - user_target: str = "windows:user", -) -> None: - if win_provider is None: - raise RuntimeError("Windows provider unavailable for registry restore") - data = json.loads(text) - scope = ( - windows_registry_provider_cls.USER_SCOPE - if target == user_target - else windows_registry_provider_cls.MACHINE_SCOPE + handler = _restore_dispatch(target) + handler( + target=target, + text=text, + scope_roots=scope_roots, + restore_dotenv_target_fn=restore_dotenv_target_fn, + restore_linux_target_fn=restore_linux_target_fn, + restore_wsl_target_fn=restore_wsl_target_fn, + restore_powershell_target_fn=restore_powershell_target_fn, + restore_windows_registry_target_fn=restore_windows_registry_target_fn, ) - current = win_provider.list_scope(scope) - for key in tuple(current): - if key not in data: - win_provider.remove_scope_value(scope, key) - for key, value in data.items(): - win_provider.set_scope_value(scope, key, str(value)) -def restore_target( - *, - target: str, - text: str, - scope_roots: List[Path], - restore_dotenv_target_fn: Callable[..., None], - restore_linux_target_fn: Callable[..., None], - restore_wsl_target_fn: Callable[..., None], - restore_powershell_target_fn: Callable[..., None], - restore_windows_registry_target_fn: Callable[..., None], -) -> None: +def _restore_dispatch(target: str): if target.startswith("dotenv:"): - restore_dotenv_target_fn(target=target, text=text, scope_roots=scope_roots) - return + return _dispatch_restore_dotenv if target.startswith("linux:"): - restore_linux_target_fn(target=target, text=text) - return + return _dispatch_restore_linux if target.startswith("wsl_dotenv:") or target.startswith("wsl:"): - restore_wsl_target_fn(target=target, text=text) - return + return _dispatch_restore_wsl if target.startswith("powershell:"): - restore_powershell_target_fn(target=target, text=text) - return + return _dispatch_restore_powershell if target.startswith("windows:"): - restore_windows_registry_target_fn(target=target, text=text) - return + return _dispatch_restore_windows raise RuntimeError(f"Unsupported restore target: {target}") + + +def _dispatch_restore_dotenv(**kwargs) -> None: + kwargs["restore_dotenv_target_fn"](target=kwargs["target"], text=kwargs["text"], scope_roots=kwargs["scope_roots"]) + + +def _dispatch_restore_linux(**kwargs) -> None: + kwargs["restore_linux_target_fn"](target=kwargs["target"], text=kwargs["text"]) + + +def _dispatch_restore_wsl(**kwargs) -> None: + kwargs["restore_wsl_target_fn"](target=kwargs["target"], text=kwargs["text"]) + + +def _dispatch_restore_powershell(**kwargs) -> None: + kwargs["restore_powershell_target_fn"](target=kwargs["target"], text=kwargs["text"]) + + +def _dispatch_restore_windows(**kwargs) -> None: + kwargs["restore_windows_registry_target_fn"](target=kwargs["target"], text=kwargs["text"]) diff --git a/env_inspector_core/service_wsl.py b/env_inspector_core/service_wsl.py index 3cd707c..94b495d 100644 --- a/env_inspector_core/service_wsl.py +++ b/env_inspector_core/service_wsl.py @@ -1,66 +1,89 @@ -from __future__ import absolute_import, division - -from pathlib import PurePosixPath -from typing import Tuple - - -def validate_wsl_distro_name(raw: str) -> str: - distro = (raw or "").strip() - if not distro or ":" in distro or "\x00" in distro: - raise RuntimeError(f"Unsupported WSL distro name: {raw!r}") - return distro - - -def validate_wsl_dotenv_path(raw: str, *, path_error: str) -> str: - candidate = (raw or "").strip() - if not candidate or "\x00" in candidate: - raise RuntimeError(path_error) - path = PurePosixPath(candidate) - if ".." in path.parts or not str(path).startswith("/"): - raise RuntimeError(path_error) - if path.name != ".env" and not path.name.startswith(".env."): - raise RuntimeError(path_error) - return str(path) - - +from __future__ import absolute_import, division + +from pathlib import PurePosixPath +from typing import Tuple + + +def validate_wsl_distro_name(raw: str) -> str: + distro = (raw or "").strip() + if not distro or ":" in distro or "\x00" in distro: + raise RuntimeError(f"Unsupported WSL distro name: {raw!r}") + return distro + + +def validate_wsl_dotenv_path(raw: str, *, path_error: str) -> str: + candidate = (raw or "").strip() + if not candidate or "\x00" in candidate: + raise RuntimeError(path_error) + path = PurePosixPath(candidate) + if ".." in path.parts or not str(path).startswith("/"): + raise RuntimeError(path_error) + if path.name != ".env" and not path.name.startswith(".env."): + raise RuntimeError(path_error) + return str(path) + + def parse_wsl_dotenv_target( - target: str, - *, - prefix: str, - validate_distro_name_fn, - validate_dotenv_path_fn, -) -> Tuple[str, str]: - raw = target[len(prefix) :] - try: - distro, path = raw.split(":", 1) + target: str, + *, + prefix: str, + validate_distro_name_fn, + validate_dotenv_path_fn, +) -> Tuple[str, str]: + raw = target[len(prefix) :] + try: + distro, path = raw.split(":", 1) except ValueError as exc: raise RuntimeError(f"Unsupported WSL target: {target}") from exc return validate_distro_name_fn(distro), validate_dotenv_path_fn(path) -def resolve_wsl_target( +def _split_wsl_target(target: str) -> Tuple[str, str]: + parts = target.split(":", 2) + if len(parts) != 3: + raise RuntimeError(f"Unsupported WSL target: {target}") + _prefix, distro, suffix = parts + return distro, suffix + + +def _resolve_standard_wsl_target( target: str, *, - dotenv_prefix: str, validate_distro_name_fn, - parse_wsl_dotenv_target_fn, linux_etc_env_path: str, ) -> Tuple[str, str, str, bool]: - if target.startswith(dotenv_prefix): - distro, path = parse_wsl_dotenv_target_fn(target) - return distro, path, "key_value", False - - if not target.startswith("wsl:"): - raise RuntimeError(f"Unsupported WSL target: {target}") - - parts = target.split(":", 2) - if len(parts) != 3: - raise RuntimeError(f"Unsupported WSL target: {target}") - - _prefix, distro, suffix = parts + distro, suffix = _split_wsl_target(target) distro_name = validate_distro_name_fn(distro) if suffix == "bashrc": return distro_name, "~/.bashrc", "export", False if suffix == "etc_environment": return distro_name, linux_etc_env_path, "key_value", True raise RuntimeError(f"Unsupported WSL target: {target}") + + +def resolve_wsl_target(*args, **kwargs) -> Tuple[str, str, str, bool]: + if not args: + raise TypeError("resolve_wsl_target requires a target argument.") + target = args[0] + if len(args) > 1: + raise TypeError("resolve_wsl_target accepts a single positional target argument only.") + + dotenv_prefix = kwargs.pop("dotenv_prefix") + validate_distro_name_fn = kwargs.pop("validate_distro_name_fn") + parse_wsl_dotenv_target_fn = kwargs.pop("parse_wsl_dotenv_target_fn") + linux_etc_env_path = kwargs.pop("linux_etc_env_path") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"Unexpected keyword argument(s): {unexpected}") + + if target.startswith(dotenv_prefix): + distro, path = parse_wsl_dotenv_target_fn(target) + return distro, path, "key_value", False + + if not target.startswith("wsl:"): + raise RuntimeError(f"Unsupported WSL target: {target}") + return _resolve_standard_wsl_target( + target, + validate_distro_name_fn=validate_distro_name_fn, + linux_etc_env_path=linux_etc_env_path, + ) diff --git a/env_inspector_gui/controller.py b/env_inspector_gui/controller.py index a33d25e..eb6f8c9 100644 --- a/env_inspector_gui/controller.py +++ b/env_inspector_gui/controller.py @@ -11,11 +11,23 @@ from .controller_actions import APP_NAME, EnvInspectorControllerActionsMixin from .dialogs import DiffPreviewDialog, DotenvTargetDialog, TargetPickerDialog -from .models import DisplayedRow, PersistedUiState, SortState +from .models import ( + DisplayedRow, + PersistedUiState, + SortState, + build_status_line, + build_effective_value_text, + has_multiple_dotenv_matches, + reconcile_selected_targets, + resolve_context_selection, + resolve_selected_targets, + select_theme_name, + select_target_dialog_result, + summarize_operation_result, +) from .path_actions import is_openable_local_path -from .secret_policy import resolve_copy_payload from .state_store import load_ui_state, sanitize_loaded_state, save_ui_state -from .table_logic import build_display_rows, sort_display_rows, toggle_sort +from .table_logic import DisplayRowsRequest, build_display_rows, sort_display_rows, toggle_sort from .view import EnvInspectorView @@ -107,18 +119,9 @@ def _initialize_view(self, tk: Any, ttk: Any, boot_state: PersistedUiState) -> N def _apply_theme(self) -> None: style = self.ttk.Style(self.tk) themes = set(style.theme_names()) - - if os.name == "nt": - for preferred in ("vista", "xpnative"): - if preferred in themes: - style.theme_use(preferred) - break - else: - if "clam" in themes: - style.theme_use("clam") - else: - if "clam" in themes: - style.theme_use("clam") + selected_theme = select_theme_name(os.name, tuple(themes)) + if selected_theme is not None: + style.theme_use(selected_theme) style.configure("Treeview", rowheight=24) @@ -258,13 +261,15 @@ def on_tree_selected(self) -> None: def _update_context_values(self) -> None: contexts = self.service.list_contexts() self.view.set_context_values(contexts) - - if self.context_var.get() not in contexts: - self.context_var.set(contexts[0] if contexts else self.service.runtime_context) - - distros = [c.split(":", 1)[1] for c in contexts if c.startswith("wsl:")] - if self.wsl_distro_var.get() not in distros: - self.wsl_distro_var.set(distros[0] if distros else "") + selection = resolve_context_selection( + contexts=contexts, + current_context=self.context_var.get(), + current_wsl_distro=self.wsl_distro_var.get(), + runtime_context=self.service.runtime_context, + ) + self.context_var.set(selection.context) + self.wsl_distro_var.set(selection.wsl_distro) + distros = selection.distros self.view.set_wsl_distros(distros, enabled=bool(distros)) def _fetch_records(self) -> None: @@ -284,12 +289,7 @@ def _fetch_records(self) -> None: def _reconcile_targets(self) -> None: available = self.service.available_targets(self.records_raw, context=self.context_var.get()) - if not self.selected_targets: - self.selected_targets = available - else: - self.selected_targets = [target for target in self.selected_targets if target in available] - if not self.selected_targets: - self.selected_targets = available + self.selected_targets = reconcile_selected_targets(self.selected_targets, available) self.targets_summary_var.set(f"Targets: {len(self.selected_targets)} selected") @@ -298,11 +298,13 @@ def _render_table(self) -> None: self.rows_by_item.clear() filtered = build_display_rows( - self.records_raw, - context=self.context_var.get(), - query=self.filter_text.get(), - only_secrets=bool(self.only_secrets.get()), - show_secrets=bool(self.show_secrets.get()), + DisplayRowsRequest( + records=self.records_raw, + context=self.context_var.get(), + query=self.filter_text.get(), + only_secrets=bool(self.only_secrets.get()), + show_secrets=bool(self.show_secrets.get()), + ) ) self.displayed_rows = sort_display_rows(filtered, self.sort_state) @@ -326,11 +328,7 @@ def _render_table(self) -> None: def _update_status_line(self, shown: int, total: int) -> None: context = self.context_var.get() or self.service.runtime_context - if self.last_refresh_at is None: - when = "-" - else: - when = self.last_refresh_at.strftime("%H:%M:%S") - self._set_status(f"Showing {shown} / {total} entries | Context: {context} | Last refresh: {when}") + self._set_status(build_status_line(shown, total, context, self.last_refresh_at)) def _set_status(self, text: str) -> None: view = getattr(self, "view", None) @@ -355,10 +353,7 @@ def refresh_data(self) -> None: self._render_table() key = self.key_text.get().strip() - if key: - self._update_effective(key) - else: - self.effective_value_var.set("Effective: (select key)") + self._update_effective(key) if getattr(self, "view", None) is not None: self._on_row_selected_update_details(self._selected_row()) @@ -370,35 +365,31 @@ def refresh_data(self) -> None: def _update_effective(self, key: str) -> None: context = self.context_var.get() or self.service.runtime_context rec = self.service.resolve_effective(key, context, self.records_raw) - if rec is None: - self.effective_value_var.set("Effective: (not found)") - return - - row_value, _ = resolve_copy_payload( - rec, - show_secrets=bool(self.show_secrets.get()), - confirm_raw=lambda: False, - as_pair=False, + self.effective_value_var.set( + build_effective_value_text( + rec, + context=context, + key=key, + show_secrets=bool(self.show_secrets.get()), + ) ) - self.effective_value_var.set(f"Effective ({context}): {key}={row_value} from {rec.source_type}") - def choose_targets(self) -> None: + def choose_targets(self) -> List[str] | None: available = self.service.available_targets(self.records_raw, context=self.context_var.get()) if not available: self.messagebox.showinfo(APP_NAME, "No writable targets found in current context.") - return + return None dialog = TargetPickerDialog(self.tk, targets=available, selected=self.selected_targets) self.tk.wait_window(dialog.win) - if dialog.result is None: - return - if not dialog.result: - self.messagebox.showinfo(APP_NAME, "No targets selected.") - return + selected = select_target_dialog_result(dialog.result, messagebox=self.messagebox, app_name=APP_NAME) + if selected is None: + return None - self.selected_targets = dialog.result + self.selected_targets = selected self.targets_summary_var.set(f"Targets: {len(self.selected_targets)} selected") self._persist_state() + return list(self.selected_targets) def _maybe_choose_dotenv_targets(self, key: str, targets: List[str]) -> List[str] | None: dotenv_targets = self._collect_dotenv_targets(targets) @@ -418,13 +409,7 @@ def _collect_dotenv_targets(targets: List[str]) -> List[str]: return [target for target in targets if target.startswith(("dotenv:", "wsl_dotenv:"))] def _has_multiple_dotenv_matches(self, key: str) -> bool: - found = 0 - for rec in self.records_raw: - if rec.name == key and rec.source_type in {"dotenv", "wsl_dotenv"}: - found += 1 - if found > 1: - return True - return False + return has_multiple_dotenv_matches(self.records_raw, key) def _preview_operation(self, action: str, key: str, value: str, targets: List[str]) -> List[Dict[str, Any]]: if action == "set": @@ -468,14 +453,12 @@ def _resolve_operation_inputs(self) -> Tuple[str, str, List[str]] | None: self.messagebox.showerror(APP_NAME, "Key is required.") return None - targets = list(self.selected_targets) - if not targets: - self.choose_targets() - targets = list(self.selected_targets) - if not targets: - return None - - scoped_targets = self._maybe_choose_dotenv_targets(key, targets) + scoped_targets = resolve_selected_targets( + selected_targets=self.selected_targets, + choose_targets=self.choose_targets, + key=key, + maybe_choose_dotenv_targets=self._maybe_choose_dotenv_targets, + ) if scoped_targets is None: return None return key, value, scoped_targets @@ -495,21 +478,12 @@ def _safe_apply(self, action: str, key: str, value: str, targets: List[str]) -> return None def _report_operation_result(self, action: str, result: Dict[str, Any]) -> None: - if isinstance(result, dict) and "results" in result: - failed = [x for x in result["results"] if not x.get("success")] - if failed: - self.messagebox.showerror( - APP_NAME, - f"{action.title()} had failures:\n" + "\n".join(x.get("error_message", "") for x in failed), - ) - else: - self._set_status(f"{action.title()} succeeded for {len(result['results'])} targets") + summary = summarize_operation_result(action, result) + if summary.error_message is not None: + self.messagebox.showerror(APP_NAME, summary.error_message) return - - if result.get("success"): - self._set_status(f"{action.title()} succeeded ({result.get('operation_id')})") - else: - self.messagebox.showerror(APP_NAME, f"{action.title()} failed: {result.get('error_message')}") + if summary.status_message is not None: + self._set_status(summary.status_message) class EnvInspectorApp: diff --git a/env_inspector_gui/dialogs.py b/env_inspector_gui/dialogs.py index 28e8736..708e440 100644 --- a/env_inspector_gui/dialogs.py +++ b/env_inspector_gui/dialogs.py @@ -3,6 +3,13 @@ from typing import Any, Callable, Dict, List, Set, Tuple +class _PreviewTabDeps: + def __init__(self, mono: Any, ttk: Any, scrolledtext: Any) -> None: + self.mono = mono + self.ttk = ttk + self.scrolledtext = scrolledtext + + class TargetPickerDialog: def __init__(self, parent: Any, targets: List[str], selected: List[str] | None = None) -> None: import tkinter as tk @@ -206,7 +213,7 @@ def __init__( notebook.pack(fill="both", expand=True) mono = tkfont.nametofont("TkFixedFont") - self._build_preview_tabs(notebook, previews, mono, ttk, scrolledtext) + self._build_preview_tabs(notebook, previews, _PreviewTabDeps(mono, ttk, scrolledtext)) btns = ttk.Frame(frame) btns.pack(fill="x", pady=(10, 0)) @@ -218,28 +225,24 @@ def _build_preview_tabs( self, notebook: Any, previews: List[Dict[str, Any]], - mono: Any, - ttk: Any, - scrolledtext: Any, + deps: _PreviewTabDeps, ) -> None: for idx, preview in enumerate(previews): - self._build_preview_tab(notebook, idx, preview, mono, ttk, scrolledtext) + self._build_preview_tab(notebook, idx, preview, deps) def _build_preview_tab( self, notebook: Any, idx: int, preview: Dict[str, Any], - mono: Any, - ttk: Any, - scrolledtext: Any, + deps: _PreviewTabDeps, ) -> None: - tab = ttk.Frame(notebook, padding=8) + tab = deps.ttk.Frame(notebook, padding=8) target = str(preview.get("target", f"target-{idx + 1}")) notebook.add(tab, text=f"{idx + 1}. {target}") - self._build_summary(tab, preview, target, ttk) + self._build_summary(tab, preview, target, deps.ttk) - txt = scrolledtext.ScrolledText(tab, wrap="none", font=mono) + txt = deps.scrolledtext.ScrolledText(tab, wrap="none", font=deps.mono) txt.pack(fill="both", expand=True) txt.tag_configure("diff_add", foreground="#1f7a1f") txt.tag_configure("diff_remove", foreground="#9b1c1c") diff --git a/env_inspector_gui/models.py b/env_inspector_gui/models.py index 46a4da2..18f4921 100644 --- a/env_inspector_gui/models.py +++ b/env_inspector_gui/models.py @@ -1,99 +1,249 @@ -from __future__ import absolute_import, division - -from typing import Dict, List +from __future__ import absolute_import, division + +from typing import Any, Callable, Dict, Iterable, List, Mapping, Sequence from dataclasses import asdict, dataclass, field from env_inspector_core.models import EnvRecord - -def _coerce_text(payload: Dict[str, object], key: str, default: str) -> str: - value = payload.get(key, default) - return str(value or default) - - -def _coerce_flag(payload: Dict[str, object], key: str, default: bool = False) -> bool: - return bool(payload.get(key, default)) - - -def _coerce_items(payload: Dict[str, object], key: str) -> List[str]: - value = payload.get(key) or [] - if not isinstance(value, list): - return [] - return [str(item) for item in value if isinstance(item, str)] - - +from .secret_policy import resolve_copy_payload + + +def _coerce_text(payload: Dict[str, object], key: str, default: str) -> str: + value = payload.get(key, default) + return str(value or default) + + +def _coerce_flag(payload: Dict[str, object], key: str, default: bool = False) -> bool: + return bool(payload.get(key, default)) + + +def _coerce_items(payload: Dict[str, object], key: str) -> List[str]: + value = payload.get(key) or [] + if not isinstance(value, list): + return [] + return [str(item) for item in value if isinstance(item, str)] + + def _coerce_number(payload: Dict[str, object], key: str, default: int) -> int: value = payload.get(key, default) + result = default if isinstance(value, bool): - return default - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str): + result = default + elif isinstance(value, int): + result = value + elif isinstance(value, float): + result = int(value) + elif isinstance(value, str): text = value.strip() - if not text: - return default - try: - return int(text) - except ValueError: - return default - return default + if text: + try: + result = int(text) + except ValueError: + result = default + return result + + +@dataclass(frozen=True) +class SortState: + column: str = "name" + descending: bool = False + + +@dataclass +class PersistedUiState: + version: int = 1 + window_geometry: str = "1480x860" + root_path: str = "" + context: str = "" + show_secrets: bool = False + only_secrets: bool = False + filter_text: str = "" + selected_targets: List[str] = field(default_factory=list) + sort_column: str = "name" + sort_descending: bool = False + wsl_distro: str = "" + wsl_path: str = "" + scan_depth: int = 5 + + def to_dict(self) -> Dict[str, object]: + return asdict(self) + + @classmethod + def from_dict(cls, payload: Dict[str, object]) -> "PersistedUiState": + return cls( + version=_coerce_number(payload, "version", 1), + window_geometry=_coerce_text(payload, "window_geometry", "1480x860"), + root_path=_coerce_text(payload, "root_path", ""), + context=_coerce_text(payload, "context", ""), + show_secrets=_coerce_flag(payload, "show_secrets"), + only_secrets=_coerce_flag(payload, "only_secrets"), + filter_text=_coerce_text(payload, "filter_text", ""), + selected_targets=_coerce_items(payload, "selected_targets"), + sort_column=_coerce_text(payload, "sort_column", "name"), + sort_descending=_coerce_flag(payload, "sort_descending"), + wsl_distro=_coerce_text(payload, "wsl_distro", ""), + wsl_path=_coerce_text(payload, "wsl_path", ""), + scan_depth=_coerce_number(payload, "scan_depth", 5), + ) + + +@dataclass +class DisplayedRow: + record: EnvRecord + visible_value: str + search_value: str + source_label: str + secret_text: str + persistent_text: str + mutable_text: str + writable_text: str + requires_privilege_text: str + original_index: int @dataclass(frozen=True) -class SortState: - column: str = "name" - descending: bool = False - - -@dataclass -class PersistedUiState: - version: int = 1 - window_geometry: str = "1480x860" - root_path: str = "" - context: str = "" - show_secrets: bool = False - only_secrets: bool = False - filter_text: str = "" - selected_targets: List[str] = field(default_factory=list) - sort_column: str = "name" - sort_descending: bool = False - wsl_distro: str = "" - wsl_path: str = "" - scan_depth: int = 5 - - def to_dict(self) -> Dict[str, object]: - return asdict(self) - - @classmethod - def from_dict(cls, payload: Dict[str, object]) -> "PersistedUiState": - return cls( - version=_coerce_number(payload, "version", 1), - window_geometry=_coerce_text(payload, "window_geometry", "1480x860"), - root_path=_coerce_text(payload, "root_path", ""), - context=_coerce_text(payload, "context", ""), - show_secrets=_coerce_flag(payload, "show_secrets"), - only_secrets=_coerce_flag(payload, "only_secrets"), - filter_text=_coerce_text(payload, "filter_text", ""), - selected_targets=_coerce_items(payload, "selected_targets"), - sort_column=_coerce_text(payload, "sort_column", "name"), - sort_descending=_coerce_flag(payload, "sort_descending"), - wsl_distro=_coerce_text(payload, "wsl_distro", ""), - wsl_path=_coerce_text(payload, "wsl_path", ""), - scan_depth=_coerce_number(payload, "scan_depth", 5), - ) +class ContextSelection: + context: str + wsl_distro: str + distros: List[str] -@dataclass -class DisplayedRow: - record: EnvRecord - visible_value: str - search_value: str - source_label: str - secret_text: str - persistent_text: str - mutable_text: str - writable_text: str - requires_privilege_text: str - original_index: int +@dataclass(frozen=True) +class OperationResultSummary: + status_message: str | None + error_message: str | None + + +def select_theme_name(os_name: str, themes: Sequence[str]) -> str | None: + theme_set = set(themes) + if os_name == "nt": + for preferred in ("vista", "xpnative"): + if preferred in theme_set: + return preferred + if "clam" in theme_set: + return "clam" + return None + if "clam" in theme_set: + return "clam" + return None + + +def resolve_context_selection( + *, + contexts: Sequence[str], + current_context: str, + current_wsl_distro: str, + runtime_context: str, +) -> ContextSelection: + distros = [context.split(":", 1)[1] for context in contexts if context.startswith("wsl:")] + if current_context in contexts: + context = current_context + elif contexts: + context = contexts[0] + else: + context = runtime_context + + if current_wsl_distro in distros: + wsl_distro = current_wsl_distro + elif distros: + wsl_distro = distros[0] + else: + wsl_distro = "" + return ContextSelection(context=context, wsl_distro=wsl_distro, distros=distros) + + +def reconcile_selected_targets(selected_targets: Sequence[str], available_targets: Sequence[str]) -> List[str]: + if not selected_targets: + return list(available_targets) + remaining = [target for target in selected_targets if target in available_targets] + return remaining or list(available_targets) + + +def has_multiple_dotenv_matches(records: Iterable[EnvRecord], key: str) -> bool: + found = 0 + for record in records: + if record.name == key and record.source_type in {"dotenv", "wsl_dotenv"}: + found += 1 + if found > 1: + return True + return False + + +def build_status_line(shown: int, total: int, context: str, last_refresh_at) -> str: + when = "-" if last_refresh_at is None else last_refresh_at.strftime("%H:%M:%S") + return f"Showing {shown} / {total} entries | Context: {context} | Last refresh: {when}" + + +def resolve_selected_targets( + *, + selected_targets: Sequence[str], + choose_targets: Callable[[], List[str] | None], + key: str, + maybe_choose_dotenv_targets: Callable[[str, List[str]], List[str] | None], +) -> List[str] | None: + targets = list(selected_targets) + if not targets: + targets = choose_targets() or [] + if not targets: + return None + + scoped_targets = maybe_choose_dotenv_targets(key, list(targets)) + return scoped_targets + + +def summarize_operation_result(action: str, result: Mapping[str, Any]) -> OperationResultSummary: + if isinstance(result, dict) and "results" in result: + failures = _batch_failures(result["results"]) + if failures: + return OperationResultSummary( + status_message=None, + error_message=f"{action.title()} had failures:\n" + "\n".join(str(item.get("error_message", "")) for item in failures), + ) + return OperationResultSummary( + status_message=f"{action.title()} succeeded for {len(result['results'])} targets", + error_message=None, + ) + + if result.get("success"): + return OperationResultSummary( + status_message=f"{action.title()} succeeded ({result.get('operation_id')})", + error_message=None, + ) + return OperationResultSummary( + status_message=None, + error_message=f"{action.title()} failed: {result.get('error_message')}", + ) + + +def _batch_failures(results: Any) -> List[Mapping[str, Any]]: + return [item for item in results if isinstance(item, dict) and not item.get("success")] + + +def select_target_dialog_result(result: List[str] | None, *, messagebox: Any, app_name: str) -> List[str] | None: + if result is None: + return None + if not result: + messagebox.showinfo(app_name, "No targets selected.") + return None + return list(result) + + +def build_effective_value_text( + record: EnvRecord | None, + *, + context: str, + key: str, + show_secrets: bool, +) -> str: + if not key: + return "Effective: (select key)" + if record is None: + return "Effective: (not found)" + + row_value, _ = resolve_copy_payload( + record, + show_secrets=show_secrets, + confirm_raw=lambda: False, + as_pair=False, + ) + return f"Effective ({context}): {key}={row_value} from {record.source_type}" diff --git a/env_inspector_gui/table_logic.py b/env_inspector_gui/table_logic.py index 5d61c9f..3a618fe 100644 --- a/env_inspector_gui/table_logic.py +++ b/env_inspector_gui/table_logic.py @@ -1,6 +1,8 @@ from __future__ import absolute_import, division -from typing import List +from dataclasses import dataclass +from typing import Iterable, List + from .models import DisplayedRow, SortState from .secret_policy import build_search_value, build_visible_value @@ -28,26 +30,28 @@ def _to_displayed_row(rec, *, show_secrets: bool, search_value: str, idx: int) - ) -def build_display_rows( - records, - *, - context: str, - query: str, - only_secrets: bool, - show_secrets: bool, -) -> List[DisplayedRow]: +@dataclass(frozen=True) +class DisplayRowsRequest: + records: Iterable + context: str + query: str + only_secrets: bool + show_secrets: bool + + +def build_display_rows(request: DisplayRowsRequest) -> List[DisplayedRow]: rows: List[DisplayedRow] = [] - query_text = query.strip().lower() + query_text = request.query.strip().lower() - for idx, rec in enumerate(records): - if not _record_matches_filters(rec, context=context, only_secrets=only_secrets): + for idx, rec in enumerate(request.records): + if not _record_matches_filters(rec, context=request.context, only_secrets=request.only_secrets): continue - search_value = build_search_value(rec, show_secrets=show_secrets) + search_value = build_search_value(rec, show_secrets=request.show_secrets) if query_text and query_text not in search_value: continue - rows.append(_to_displayed_row(rec, show_secrets=show_secrets, search_value=search_value, idx=idx)) + rows.append(_to_displayed_row(rec, show_secrets=request.show_secrets, search_value=search_value, idx=idx)) return rows diff --git a/scripts/quality/_codacy_zero_impl.py b/scripts/quality/_codacy_zero_impl.py new file mode 100644 index 0000000..d2de61e --- /dev/null +++ b/scripts/quality/_codacy_zero_impl.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import, division + +import argparse +from dataclasses import replace +import urllib.error +import sys +from typing import Any, List, Tuple + +try: + from . import _codacy_zero_support as _support +except ImportError: # pragma: no cover - direct script execution + import _codacy_zero_support as _support # type: ignore + +CODACY_API_HOST = _support.CODACY_API_HOST +CODACY_REQUEST_EXCEPTIONS = _support.CODACY_REQUEST_EXCEPTIONS +TOTAL_KEYS = _support.TOTAL_KEYS +CodacyRequest = _support.CodacyRequest +_fetch_sample_payload = _support._fetch_sample_payload +_first_text = _support._first_text +_format_issue_sample = _support._format_issue_sample +_provider_candidates = _support._provider_candidates +_request_json = _support._request_json +_sample_issue_findings = _support._sample_issue_findings +encode_identifier = _support.encode_identifier +request_json_https = _support.request_json_https +safe_output_path_in_workspace = _support.safe_output_path_in_workspace + + +def _public_codacy_module() -> Any | None: + return sys.modules.get("scripts.quality.check_codacy_zero") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Assert Codacy has zero total open issues.") + parser.add_argument("--provider", default="gh", help="Organization provider, for example gh") + parser.add_argument("--owner", required=True, help="Repository owner") + parser.add_argument("--repo", required=True, help="Repository name") + parser.add_argument("--branch", default="", help="Optional branch name to scope issue totals") + parser.add_argument("--token", default="", help="Codacy API token (falls back to CODACY_API_TOKEN env)") + parser.add_argument("--out-json", default="codacy-zero/codacy.json", help="Output JSON path") + parser.add_argument("--out-md", default="codacy-zero/codacy.md", help="Output markdown path") + return parser.parse_args() + + +def _extract_numeric_total(payload: dict, keys: tuple) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, (int, float)): + return int(value) + return None + + +def extract_total_open(payload: Any) -> int | None: + if not isinstance(payload, dict): + return None + + pagination = payload.get("pagination") + if isinstance(pagination, dict): + total = _extract_numeric_total(pagination, ("total", "totalItems", "count")) + if total is not None: + return total + + stack: List[Any] = [payload] + while stack: + node = stack.pop() + if isinstance(node, dict): + total = _extract_numeric_total(node, TOTAL_KEYS) + if total is not None: + return total + stack.extend(node.values()) + continue + if isinstance(node, list): + stack.extend(node) + + return None + + +def _fetch_open_issues_for_provider( + request: CodacyRequest | None = None, + **kwargs: Any, +) -> Tuple[bool, int | None, List[str], Exception | None]: + request = _resolve_codacy_request(request, kwargs) + + public = _public_codacy_module() + request_json_fn = getattr(public, "_request_json", _request_json) + sample_findings_fn = getattr(public, "_sample_issue_findings", _sample_issue_findings) + handled, open_issues, findings, error = _attempt_issue_total(request, request_json_fn) + findings.extend(_issue_total_findings(request, open_issues, handled, request_json_fn, sample_findings_fn)) + return handled, open_issues, findings, error + + +def _attempt_issue_total( + request: CodacyRequest, + request_json_fn: Any, +) -> Tuple[bool, int | None, List[str], Exception | None]: + findings: List[str] = [] + + try: + return True, _request_issue_total(request, request_json_fn), findings, None + except urllib.error.HTTPError as exc: + handled, error = _handle_http_error(exc, findings) + return handled, None, findings, error + except CODACY_REQUEST_EXCEPTIONS as exc: # pragma: no cover - network/runtime surface + findings.append(f"Codacy API request failed: {exc}") + return True, None, findings, exc + + +def _resolve_codacy_request(request: CodacyRequest | None, kwargs: Any) -> CodacyRequest: + if request is None: + return CodacyRequest(**kwargs) + if kwargs: + raise TypeError("Pass either a CodacyRequest or keyword arguments, not both.") + return request + + +def _request_issue_total(request: CodacyRequest, request_json_fn: Any) -> int | None: + payload = request_json_fn(request=replace(request, limit=1, method="POST", data={})) + return extract_total_open(payload) + + +def _handle_http_error(exc: urllib.error.HTTPError, findings: List[str]) -> Tuple[bool, Exception]: + if exc.code == 404: + return False, exc + findings.append(f"Codacy API request failed: HTTP {exc.code}") + return True, exc + + +def _non_zero_issue_findings( + request: CodacyRequest, + open_issues: int, + request_json_fn: Any, + sample_findings_fn: Any, +) -> List[str]: + findings = [f"Codacy reports {open_issues} open issues (expected 0)."] + sample_payload = request_json_fn(request=replace(request, limit=20, method="POST", data={})) + findings.extend(sample_findings_fn(sample_payload)) + return findings + + +def _issue_total_findings( + request: CodacyRequest, + open_issues: int | None, + handled: bool, + request_json_fn: Any, + sample_findings_fn: Any, +) -> List[str]: + if not handled: + return [] + if open_issues is None: + return ["Codacy response did not include a parseable total issue count."] + if open_issues == 0: + return [] + return _non_zero_issue_findings(request, open_issues, request_json_fn, sample_findings_fn) + + +def _query_open_issues(request: CodacyRequest | None = None, **kwargs: Any) -> Tuple[int | None, List[str]]: + if request is None: + request = CodacyRequest(**kwargs) + elif kwargs: + raise TypeError("Pass either a CodacyRequest or keyword arguments, not both.") + + last_exc: Exception | None = None + + public = _public_codacy_module() + provider_candidates_fn = getattr(public, "_provider_candidates", _provider_candidates) + fetch_fn = getattr(public, "_fetch_open_issues_for_provider", _fetch_open_issues_for_provider) + + for candidate in provider_candidates_fn(request.provider): + handled, open_issues, findings, error = fetch_fn(request=replace(request, provider=candidate)) + if handled: + return open_issues, findings + last_exc = error + findings = [ + f"Codacy API endpoint was not found for provider(s): {', '.join(provider_candidates_fn(request.provider))}." + ] + if last_exc is not None: + findings.append(f"Last Codacy API error: {last_exc}") + return None, findings + + +def _render_md(payload: dict) -> str: + lines = [ + "# Codacy Zero Gate", + "", + f"- Status: `{payload['status']}`", + f"- Owner/repo: `{payload['owner']}/{payload['repo']}`", + f"- Branch: `{payload.get('branch') or 'default'}`", + f"- Open issues: `{payload.get('open_issues')}`", + f"- Timestamp (UTC): `{payload['timestamp_utc']}`", + "", + "## Findings", + ] + findings = payload.get("findings") or [] + if findings: + lines.extend(f"- {item}" for item in findings) + else: + lines.append("- None") + return "\n".join(lines) + "\n" diff --git a/scripts/quality/_codacy_zero_support.py b/scripts/quality/_codacy_zero_support.py new file mode 100644 index 0000000..d886dc8 --- /dev/null +++ b/scripts/quality/_codacy_zero_support.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import, division + +from dataclasses import dataclass, replace +import importlib +from pathlib import Path +import sys +import urllib.error +from typing import Any, Callable, Dict, List, Tuple, cast + +TOTAL_KEYS = ("total", "totalItems", "total_items", "count", "hits", "open_issues") +CODACY_API_HOST = "api.codacy.com" +CODACY_REQUEST_EXCEPTIONS = (urllib.error.URLError, ValueError, TypeError, RuntimeError) + +RequestJsonHttps = Callable[..., Tuple[Any, Dict[str, str]]] +EncodeIdentifier = Callable[..., str] +SafeOutputPathInWorkspace = Callable[..., Path] + + +@dataclass(frozen=True) +class CodacyRequest: + provider: str + owner: str + repo: str + token: str + branch: str = "" + limit: int = 1 + method: str = "GET" + data: Dict[str, Any] | None = None + + +def _load_security_imports() -> Any: + try: + return importlib.import_module("scripts.quality._security_imports") + except ModuleNotFoundError: # pragma: no cover - direct script execution + helper_root = Path(__file__).resolve().parent + helper_root_str = str(helper_root) + if helper_root_str not in sys.path: + sys.path.insert(0, helper_root_str) + return importlib.import_module("_security_imports") + + +_security_imports = _load_security_imports() +encode_identifier = cast(EncodeIdentifier, _security_imports.encode_identifier) +request_json_https = cast(RequestJsonHttps, _security_imports.request_json_https) +safe_output_path_in_workspace = cast( + SafeOutputPathInWorkspace, + _security_imports.safe_output_path_in_workspace, +) + + +def _public_codacy_module() -> Any | None: + return sys.modules.get("scripts.quality.check_codacy_zero") + +__all__ = [ + "CODACY_API_HOST", + "CODACY_REQUEST_EXCEPTIONS", + "TOTAL_KEYS", + "CodacyRequest", + "_fetch_sample_payload", + "_format_issue_sample", + "_first_text", + "_provider_candidates", + "_request_json", + "_sample_issue_findings", + "encode_identifier", + "request_json_https", + "safe_output_path_in_workspace", +] + + +def _request_json(request: CodacyRequest | None = None, **kwargs: Any) -> Dict[str, Any]: + if request is None: + request = CodacyRequest(**kwargs) + elif kwargs: + raise TypeError("Pass either a CodacyRequest or keyword arguments, not both.") + + headers = { + "Accept": "application/json", + "api-token": request.token, + "User-Agent": "reframe-codacy-zero-gate", + } + if request.data is not None: + headers["Content-Type"] = "application/json" + + public = _public_codacy_module() + request_json_fn = cast(RequestJsonHttps, getattr(public, "request_json_https", request_json_https)) + encode_identifier_fn = cast(EncodeIdentifier, getattr(public, "encode_identifier", encode_identifier)) + + provider_slug = encode_identifier_fn(request.provider, field_name="Codacy provider") + owner_slug = encode_identifier_fn(request.owner, field_name="Codacy owner") + repo_slug = encode_identifier_fn(request.repo, field_name="Codacy repository") + + payload_data: Dict[str, Any] = dict(request.data or {}) + branch_name = str(request.branch or "").strip() + if branch_name: + payload_data = {**payload_data, "branchName": branch_name} + + payload, _headers = request_json_fn( + host=CODACY_API_HOST, + path=f"/api/v3/analysis/organizations/{provider_slug}/{owner_slug}/repositories/{repo_slug}/issues/search", + headers=headers, + method=request.method, + query={"limit": str(max(request.limit, 1))}, + data=payload_data, + ) + if not isinstance(payload, dict): + raise RuntimeError("Unexpected Codacy response payload.") + return payload + + +def _provider_candidates(preferred: str) -> List[str]: + values = [preferred, "gh", "github"] + return list(dict.fromkeys(item for item in values if item)) + + +def _first_text(issue: Dict[str, Any], keys: Tuple[str, ...]) -> str: + for key in keys: + value = str(issue.get(key) or "").strip() + if value: + return value + return "" + + +def _format_issue_sample(issue: dict) -> str | None: + pattern = _first_text(issue, ("patternId", "pattern")) + path = _first_text(issue, ("filename", "filePath", "path")) + message = _first_text(issue, ("message", "title")) + if not (pattern or path or message): + return None + + identity = pattern or "pattern:unknown" + location = path or "file:unknown" + suffix = f" - {message}" if message else "" + return f"Sample issue: `{identity}` at `{location}`{suffix}" + + +def _sample_issue_findings(payload: dict, limit: int = 5) -> List[str]: + data = payload.get("data") + if not isinstance(data, list): + return [] + + findings: List[str] = [] + for item in data: + if not isinstance(item, dict): + continue + sample = _format_issue_sample(item) + if not sample: + continue + findings.append(sample) + if len(findings) >= limit: + break + return findings + + +def _fetch_sample_payload(request: CodacyRequest) -> dict: + return _request_json(request=replace(request, limit=20, method="POST", data={})) diff --git a/scripts/quality/_required_checks_http.py b/scripts/quality/_required_checks_http.py new file mode 100644 index 0000000..014e2fd --- /dev/null +++ b/scripts/quality/_required_checks_http.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import, division + +from dataclasses import dataclass +import re +import time +from typing import Any, Dict, Optional, Tuple +import urllib.error + +try: + from ._security_imports import encode_identifier, request_json_https, safe_output_path_in_workspace +except ImportError: # pragma: no cover - direct script execution + from _security_imports import encode_identifier, request_json_https, safe_output_path_in_workspace + +GITHUB_API_HOST = "api.github.com" +_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,64}$") +_TRANSIENT_HTTP_CODES = {429, 500, 502, 503, 504} + + +@dataclass(frozen=True) +class GitHubRequest: + owner: str + repo: str + sha: str + token: str + endpoint: str + query: Optional[Dict[str, str]] = None + attempts: int = 5 + + +def _parse_repo(raw: str) -> Tuple[str, str]: + text = (raw or "").strip() + if "/" not in text: + raise ValueError("Repo must be in owner/repo format.") + owner, repo = text.split("/", 1) + return ( + encode_identifier(owner, field_name="GitHub owner"), + encode_identifier(repo, field_name="GitHub repo"), + ) + + +def _parse_sha(raw: str) -> str: + sha = (raw or "").strip() + if not _SHA_RE.fullmatch(sha): + raise ValueError("Commit SHA must be a 7-64 char hex string.") + return sha.lower() + + +def _github_headers(token: str) -> Dict[str, str]: + return { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "reframe-quality-zero-gate", + } + + +def _is_transient_http_error(exc: urllib.error.HTTPError) -> bool: + return int(exc.code) in _TRANSIENT_HTTP_CODES + + +def _should_retry_http_error(*, exc: urllib.error.HTTPError, attempt: int, attempts: int) -> bool: + return _is_transient_http_error(exc) and attempt < attempts + + +def _should_retry_url_error(*, attempt: int, attempts: int) -> bool: + return attempt < attempts + + +def _next_retry_wait(wait_seconds: int) -> int: + return min(wait_seconds * 2, 10) + + +def _request_payload_with_retry(request: GitHubRequest) -> Dict[str, Any]: + wait_seconds = 1 + last_error: Optional[Exception] = None + total_attempts = max(request.attempts, 1) + + for attempt in range(1, total_attempts + 1): + try: + payload, _headers = request_json_https( + host=GITHUB_API_HOST, + path=f"/repos/{request.owner}/{request.repo}/commits/{request.sha}/{request.endpoint}", + headers={**_github_headers(request.token)}, + query=request.query, + method="GET", + ) + if not isinstance(payload, dict): + raise RuntimeError(f"Unexpected GitHub {request.endpoint} response payload.") + return payload + except urllib.error.HTTPError as exc: + last_error = exc + if not _should_retry_http_error(exc=exc, attempt=attempt, attempts=total_attempts): + raise + except urllib.error.URLError as exc: + last_error = exc + if not _should_retry_url_error(attempt=attempt, attempts=total_attempts): + raise + + time.sleep(wait_seconds) + wait_seconds = _next_retry_wait(wait_seconds) + + if last_error is None: + raise RuntimeError(f"Failed to query GitHub endpoint: {request.endpoint}") + raise RuntimeError(f"Failed to query GitHub endpoint: {request.endpoint}") from last_error + + +def _api_get_check_runs(*, owner: str, repo: str, sha: str, token: str) -> Dict[str, Any]: + return _request_payload_with_retry( + GitHubRequest( + owner=owner, + repo=repo, + sha=sha, + token=token, + endpoint="check-runs", + query={"per_page": "100"}, + ) + ) + + +def _api_get_status(*, owner: str, repo: str, sha: str, token: str) -> Dict[str, Any]: + return _request_payload_with_retry( + GitHubRequest(owner=owner, repo=repo, sha=sha, token=token, endpoint="status") + ) + + +def _check_run_context(run: Dict[str, Any]) -> Optional[Tuple[str, Dict[str, str]]]: + name = str(run.get("name") or "").strip() + if not name: + return None + return name, { + "state": str(run.get("status") or ""), + "conclusion": str(run.get("conclusion") or ""), + "source": "check_run", + } + + +def _status_context(status: Dict[str, Any]) -> Optional[Tuple[str, Dict[str, str]]]: + name = str(status.get("context") or "").strip() + if not name: + return None + state = str(status.get("state") or "") + return name, { + "state": state, + "conclusion": state, + "source": "status", + } + + +def _collect_contexts(check_runs_payload: Dict[str, Any], status_payload: Dict[str, Any]) -> Dict[str, Dict[str, str]]: + contexts: Dict[str, Dict[str, str]] = {} + + for run in check_runs_payload.get("check_runs", []) or []: + entry = _check_run_context(run) + if entry: + key, value = entry + contexts[key] = value + + for status in status_payload.get("statuses", []) or []: + entry = _status_context(status) + if entry: + key, value = entry + contexts[key] = value + + return contexts + + +def _check_run_failure(context: str, observed: Dict[str, str]) -> Optional[str]: + state = observed.get("state") + if state != "completed": + return f"{context}: status={state}" + + conclusion = observed.get("conclusion") + if conclusion != "success": + return f"{context}: conclusion={conclusion}" + return None + + +def _status_failure(context: str, observed: Dict[str, str]) -> Optional[str]: + conclusion = observed.get("conclusion") + if conclusion != "success": + return f"{context}: state={conclusion}" + return None + + +def _evaluate(required: list[str], contexts: Dict[str, Dict[str, str]]) -> tuple[str, list[str], list[str]]: + missing: list[str] = [] + failed: list[str] = [] + + for context in required: + observed = contexts.get(context) + if not observed: + missing.append(context) + continue + + if observed.get("source") == "check_run": + failure = _check_run_failure(context, observed) + else: + failure = _status_failure(context, observed) + if failure: + failed.append(failure) + + status = "pass" if not missing and not failed else "fail" + return status, missing, failed + + +__all__ = [ + "GITHUB_API_HOST", + "GitHubRequest", + "_SHA_RE", + "_TRANSIENT_HTTP_CODES", + "_api_get_check_runs", + "_api_get_status", + "_check_run_context", + "_check_run_failure", + "_collect_contexts", + "_evaluate", + "_github_headers", + "_is_transient_http_error", + "_next_retry_wait", + "_parse_repo", + "_parse_sha", + "_request_payload_with_retry", + "_should_retry_http_error", + "_should_retry_url_error", + "_status_context", + "_status_failure", + "encode_identifier", + "request_json_https", + "safe_output_path_in_workspace", +] diff --git a/scripts/quality/_required_checks_impl.py b/scripts/quality/_required_checks_impl.py new file mode 100644 index 0000000..91af9a6 --- /dev/null +++ b/scripts/quality/_required_checks_impl.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import, division + +import argparse +import os +import time +from datetime import datetime, timezone +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + + +@dataclass(frozen=True) +class SettledChecksRequest: + owner_slug: str + repo_slug: str + repo_arg: str + sha: str + token: str + required: List[str] + timeout_seconds: int + poll_seconds: int + + +try: + from . import _required_checks_http as _http +except ImportError: # pragma: no cover - direct script execution + import _required_checks_http as _http # type: ignore + +GitHubRequest = _http.GitHubRequest +GITHUB_API_HOST = _http.GITHUB_API_HOST +_SHA_RE = _http._SHA_RE +_TRANSIENT_HTTP_CODES = _http._TRANSIENT_HTTP_CODES +_api_get_check_runs = _http._api_get_check_runs +_api_get_status = _http._api_get_status +_check_run_context = _http._check_run_context +_check_run_failure = _http._check_run_failure +_collect_contexts = _http._collect_contexts +_evaluate = _http._evaluate +_github_headers = _http._github_headers +_is_transient_http_error = _http._is_transient_http_error +_next_retry_wait = _http._next_retry_wait +_parse_repo = _http._parse_repo +_parse_sha = _http._parse_sha +_request_payload_with_retry = _http._request_payload_with_retry +_should_retry_http_error = _http._should_retry_http_error +_should_retry_url_error = _http._should_retry_url_error +_status_context = _http._status_context +_status_failure = _http._status_failure +encode_identifier = _http.encode_identifier +request_json_https = _http.request_json_https +safe_output_path_in_workspace = _http.safe_output_path_in_workspace + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Wait for required GitHub check contexts and assert they are successful.") + parser.add_argument("--repo", required=True, help="owner/repo") + parser.add_argument("--sha", required=True, help="commit SHA") + parser.add_argument("--required-context", action="append", default=[], help="Required context name") + parser.add_argument("--timeout-seconds", type=int, default=900) + parser.add_argument("--poll-seconds", type=int, default=20) + parser.add_argument("--out-json", default="quality-zero-gate/required-checks.json") + parser.add_argument("--out-md", default="quality-zero-gate/required-checks.md") + return parser.parse_args() + + +def _render_md(payload: Dict[str, Any]) -> str: + lines = [ + "# Quality Zero Gate - Required Contexts", + "", + f"- Status: `{payload['status']}`", + f"- Repo/SHA: `{payload['repo']}@{payload['sha']}`", + f"- Timestamp (UTC): `{payload['timestamp_utc']}`", + "", + "## Missing contexts", + ] + + missing = payload.get("missing") or [] + if missing: + lines.extend(f"- `{name}`" for name in missing) + else: + lines.append("- None") + + lines.extend(["", "## Failed contexts"]) + failed = payload.get("failed") or [] + if failed: + lines.extend(f"- {entry}" for entry in failed) + else: + lines.append("- None") + + return "\n".join(lines) + "\n" + + +def _required_contexts(args: argparse.Namespace) -> List[str]: + required = [item.strip() for item in args.required_context if item.strip()] + if not required: + raise SystemExit("At least one --required-context is required") + return required + + +def _github_token() -> str: + token = (os.environ.get("GITHUB_TOKEN", "") or os.environ.get("GH_TOKEN", "")).strip() + if not token: + raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required") + return token + + +def _snapshot( + *, + repo_arg: str, + sha: str, + required: List[str], + contexts: Dict[str, Dict[str, str]], +) -> Dict[str, Any]: + status, missing, failed = _evaluate(required, contexts) + return { + "status": status, + "repo": repo_arg, + "sha": sha, + "required": required, + "missing": missing, + "failed": failed, + "contexts": contexts, + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + } + + +def _has_in_progress_check_run(contexts: Dict[str, Dict[str, str]]) -> bool: + for observed in contexts.values(): + if observed.get("source") == "check_run" and observed.get("state") != "completed": + return True + return False + + +def _should_wait(payload: Dict[str, Any]) -> bool: + if payload["status"] == "pass": + return False + if payload["missing"]: + return True + return _has_in_progress_check_run(payload["contexts"]) + + +def _collect_until_settled(request: SettledChecksRequest) -> Dict[str, Any]: + deadline = time.time() + max(request.timeout_seconds, 1) + final_payload: Optional[Dict[str, Any]] = None + + while time.time() <= deadline: + check_runs = _api_get_check_runs(owner=request.owner_slug, repo=request.repo_slug, sha=request.sha, token=request.token) + statuses = _api_get_status(owner=request.owner_slug, repo=request.repo_slug, sha=request.sha, token=request.token) + contexts = _collect_contexts(check_runs, statuses) + + final_payload = _snapshot(repo_arg=request.repo_arg, sha=request.sha, required=request.required, contexts=contexts) + if not _should_wait(final_payload): + break + time.sleep(max(request.poll_seconds, 1)) + + if final_payload is None: + raise SystemExit("No payload collected") + return final_payload diff --git a/scripts/quality/assert_coverage_100.py b/scripts/quality/assert_coverage_100.py index 9682fa0..6dceae3 100644 --- a/scripts/quality/assert_coverage_100.py +++ b/scripts/quality/assert_coverage_100.py @@ -1,344 +1,351 @@ -#!/usr/bin/env python3 - -from __future__ import absolute_import, division - -import argparse -import json -import os -import posixpath -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple - -_SCRIPT_DIR = Path(__file__).resolve().parent -_HELPER_ROOT = _SCRIPT_DIR if os.path.exists(_SCRIPT_DIR / "security_helpers.py") else _SCRIPT_DIR.parent -if str(_HELPER_ROOT) not in sys.path: - sys.path.insert(0, str(_HELPER_ROOT)) - - -def _load_security_helpers(): - from security_helpers import ( - safe_input_file_path_in_workspace as _safe_input_helper, - safe_output_path_in_workspace as _safe_output_helper, - ) - - return _safe_input_helper, _safe_output_helper - - -SAFE_INPUT_FILE_PATH_IN_WORKSPACE, SAFE_OUTPUT_PATH_IN_WORKSPACE = _load_security_helpers() - - -@dataclass -class CoverageStats: - name: str - path: str - covered: int - total: int - - @property - def percent(self) -> float: - if self.total <= 0: - return 100.0 - return (self.covered / self.total) * 100.0 - - -_PAIR_RE = re.compile(r"^(?P[^=]+)=(?P.+)$") -_XML_LINES_VALID_RE = re.compile(r'lines-valid="(\d+(?:\.\d+)?)"') -_XML_LINES_COVERED_RE = re.compile(r'lines-covered="(\d+(?:\.\d+)?)"') -_XML_LINE_HITS_RE = re.compile(r"]*\bhits=\"(\d+(?:\.\d+)?)\"") -_XML_FILENAME_RE = re.compile(r"""<[^>]+\bfilename=(?P["'])(?P.*?)(?P=quote)""") -_NONE_LIST_ITEM = "- None" - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Assert minimum coverage for all declared components.") - parser.add_argument("--xml", action="append", default=[], help="Coverage XML input: name=path") - parser.add_argument("--lcov", action="append", default=[], help="LCOV input: name=path") - parser.add_argument( - "--require-source", - action="append", - default=[], - help="Workspace-relative file or directory that must appear in the coverage inputs.", - ) - parser.add_argument( - "--min-percent", - type=float, - default=100.0, - help="Minimum required coverage percentage for each component and combined summary.", - ) - parser.add_argument("--out-json", default="coverage-100/coverage.json", help="Output JSON path") - parser.add_argument("--out-md", default="coverage-100/coverage.md", help="Output markdown path") - return parser.parse_args() - - -def parse_named_path(value: str) -> Tuple[str, Path]: - match = _PAIR_RE.match(value.strip()) - if not match: - raise ValueError(f"Invalid input '{value}'. Expected format: name=path") - name = match.group("name").strip() - raw_path = match.group("path").strip() - candidate = SAFE_INPUT_FILE_PATH_IN_WORKSPACE(raw_path) - return name, candidate - - -def parse_coverage_xml(name: str, path: Path) -> CoverageStats: - text = path.read_text(encoding="utf-8") # lgtm [py/path-injection] - lines_valid_match = _XML_LINES_VALID_RE.search(text) - lines_covered_match = _XML_LINES_COVERED_RE.search(text) - - if lines_valid_match and lines_covered_match: - total = int(float(lines_valid_match.group(1))) - covered = int(float(lines_covered_match.group(1))) - return CoverageStats(name=name, path=str(path), covered=covered, total=total) - - total = 0 - covered = 0 - for hits_raw in _XML_LINE_HITS_RE.findall(text): - total += 1 - if int(float(hits_raw)) > 0: - covered += 1 - - return CoverageStats(name=name, path=str(path), covered=covered, total=total) - - -def _normalize_source_path(raw_path: str) -> str: - text = posixpath.normpath(raw_path.strip().replace("\\", "/")) - if not text: - return "" - if text == ".": - return "" - - workspace_root = posixpath.normpath(Path.cwd().resolve(strict=False).as_posix()) - if text == workspace_root: - return "" - if text.startswith(f"{workspace_root}/"): - return text[len(workspace_root) + 1 :] - return text - - -def coverage_sources_from_xml(path: Path) -> Set[str]: - text = path.read_text(encoding="utf-8") # lgtm [py/path-injection] - covered_sources: Set[str] = set() - for match in _XML_FILENAME_RE.finditer(text): - filename = _normalize_source_path(match.group("value")) - if filename: - covered_sources.add(filename) - return covered_sources - - -def parse_lcov(name: str, path: Path) -> CoverageStats: - total = 0 - covered = 0 - - for raw in path.read_text(encoding="utf-8").splitlines(): # lgtm [py/path-injection] - line = raw.strip() - if line.startswith("LF:"): - total += int(line.split(":", 1)[1]) - elif line.startswith("LH:"): - covered += int(line.split(":", 1)[1]) - - return CoverageStats(name=name, path=str(path), covered=covered, total=total) - - -def coverage_sources_from_lcov(path: Path) -> Set[str]: - covered_sources: Set[str] = set() - for raw in path.read_text(encoding="utf-8").splitlines(): # lgtm [py/path-injection] - line = raw.strip() - if not line.startswith("SF:"): - continue - filename = _normalize_source_path(line.split(":", 1)[1]) - if filename: - covered_sources.add(filename) - return covered_sources - - -def _matches_required_source(source_path: str, required_source: str) -> bool: - normalized_required = _normalize_source_path(required_source).rstrip("/") - if not normalized_required: - return False - return source_path == normalized_required or source_path.startswith(f"{normalized_required}/") - - -def _find_missing_required_sources(reported_sources: Set[str], required_sources: List[str]) -> List[str]: - missing: List[str] = [] - for required_source in required_sources: - normalized_required = _normalize_source_path(required_source).rstrip("/") - if not normalized_required: - continue - if any(_matches_required_source(source_path, normalized_required) for source_path in reported_sources): - continue - missing.append(normalized_required) - return missing - - -def _is_tests_only_report(reported_sources: Set[str]) -> bool: - return bool(reported_sources) and all( - source_path == "tests" or source_path.startswith("tests/") for source_path in reported_sources - ) - - -def _coverage_findings(stats: List[CoverageStats], min_percent: float) -> List[str]: - findings: List[str] = [] - for item in stats: - if item.percent < min_percent: - findings.append( - f"{item.name} coverage below {min_percent:.2f}%: {item.percent:.2f}% ({item.covered}/{item.total})" - ) - - combined_total = sum(item.total for item in stats) - combined_covered = sum(item.covered for item in stats) - combined = 100.0 if combined_total <= 0 else (combined_covered / combined_total) * 100.0 - if combined < min_percent: - findings.append( - f"combined coverage below {min_percent:.2f}%: {combined:.2f}% ({combined_covered}/{combined_total})" - ) - return findings - - -def _source_findings(reported_sources: Set[str], required_sources: List[str]) -> List[str]: - findings: List[str] = [] - if _is_tests_only_report(reported_sources): - findings.append("coverage inputs only reference tests/ paths; first-party sources are missing.") - - for required_source in _find_missing_required_sources(reported_sources, required_sources): - findings.append(f"missing required source path: {required_source}") - return findings - - -def evaluate( - stats: List[CoverageStats], - min_percent: float, - *, - required_sources: Optional[List[str]] = None, - reported_sources: Optional[Set[str]] = None, -) -> Tuple[str, List[str]]: - normalized_sources = reported_sources or set() - findings = _coverage_findings(stats, min_percent) - findings.extend(_source_findings(normalized_sources, required_sources or [])) - status = "pass" if not findings else "fail" - return status, findings - - -def _append_component_lines(lines: List[str], payload: Dict[str, Any]) -> None: - components = payload.get("components") or [] - if components: - for item in components: - lines.append( - f"- `{item['name']}`: `{item['percent']:.2f}%` ({item['covered']}/{item['total']}) from `{item['path']}`" - ) - return - lines.append(_NONE_LIST_ITEM) - - -def _append_covered_source_lines(lines: List[str], payload: Dict[str, Any]) -> None: - sources = payload.get("covered_sources") or [] - if sources: - lines.extend(f"- `{source_path}`" for source_path in sources) - return - lines.append(_NONE_LIST_ITEM) - - -def _append_finding_lines(lines: List[str], payload: Dict[str, Any]) -> None: - findings = payload.get("findings") or [] - if findings: - lines.extend(f"- {finding}" for finding in findings) - return - lines.append(_NONE_LIST_ITEM) - - -def _render_md(payload: Dict[str, Any]) -> str: - lines = [ - "# Coverage 100 Gate", - "", - f"- Status: `{payload['status']}`", - f"- Minimum required coverage: `{payload['min_percent']:.2f}%`", - f"- Timestamp (UTC): `{payload['timestamp_utc']}`", - "", - "## Components", - ] - _append_component_lines(lines, payload) - - lines.extend(["", "## Covered sources"]) - _append_covered_source_lines(lines, payload) - - lines.extend(["", "## Findings"]) - _append_finding_lines(lines, payload) - - return "\n".join(lines) + "\n" - - -def _build_payload(stats: List[CoverageStats], covered_sources: Set[str], min_percent: float, findings: List[str], status: str) -> Dict[str, Any]: - return { - "status": status, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "min_percent": min_percent, - "components": [ - { - "name": item.name, - "path": item.path, - "covered": item.covered, - "total": item.total, - "percent": item.percent, - } - for item in stats - ], - "covered_sources": sorted(covered_sources), - "findings": findings, - } - - -def _write_outputs(payload: Dict[str, Any], *, out_json: Path, out_md: Path) -> str: - os.makedirs(out_json.parent, exist_ok=True) - os.makedirs(out_md.parent, exist_ok=True) - with open(out_json, "w", encoding="utf-8") as handle: - handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") - rendered = _render_md(payload) - with open(out_md, "w", encoding="utf-8") as handle: - handle.write(rendered) - print(rendered, end="") - return rendered - - -def main() -> int: - args = _parse_args() - - stats: List[CoverageStats] = [] - covered_sources: Set[str] = set() - for item in args.xml: - name, path = parse_named_path(item) - stats.append(parse_coverage_xml(name, path)) - covered_sources.update(coverage_sources_from_xml(path)) - for item in args.lcov: - name, path = parse_named_path(item) - stats.append(parse_lcov(name, path)) - covered_sources.update(coverage_sources_from_lcov(path)) - - if not stats: - raise SystemExit("No coverage files were provided; pass --xml and/or --lcov inputs.") - - min_percent = max(0.0, min(100.0, float(args.min_percent))) - status, findings = evaluate( - stats, - min_percent, - required_sources=list(args.require_source), - reported_sources=covered_sources, - ) - payload = _build_payload(stats, covered_sources, min_percent, findings, status) - - try: - out_json = SAFE_OUTPUT_PATH_IN_WORKSPACE(args.out_json, "coverage-100/coverage.json") - out_md = SAFE_OUTPUT_PATH_IN_WORKSPACE(args.out_md, "coverage-100/coverage.md") - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - _write_outputs(payload, out_json=out_json, out_md=out_md) - - return 0 if status == "pass" else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 + +from __future__ import absolute_import, division + +import argparse +import importlib +import json +import os +import posixpath +import re +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +def _load_security_helpers(): + try: + security_imports = importlib.import_module("scripts.quality._security_imports") + except ModuleNotFoundError: # pragma: no cover - direct script execution + helper_root = Path(__file__).resolve().parent + helper_root_str = str(helper_root) + if helper_root_str not in sys.path: + sys.path.insert(0, helper_root_str) + security_imports = importlib.import_module("_security_imports") + + return ( + security_imports.safe_input_file_path_in_workspace, + security_imports.safe_output_path_in_workspace, + ) + + +SAFE_INPUT_FILE_PATH_IN_WORKSPACE, SAFE_OUTPUT_PATH_IN_WORKSPACE = _load_security_helpers() + + +@dataclass +class CoverageStats: + name: str + path: str + covered: int + total: int + + @property + def percent(self) -> float: + if self.total <= 0: + return 100.0 + return (self.covered / self.total) * 100.0 + + +_PAIR_RE = re.compile(r"^(?P[^=]+)=(?P.+)$") +_XML_LINES_VALID_RE = re.compile(r'lines-valid="(\d+(?:\.\d+)?)"') +_XML_LINES_COVERED_RE = re.compile(r'lines-covered="(\d+(?:\.\d+)?)"') +_XML_LINE_HITS_RE = re.compile(r"]*\bhits=\"(\d+(?:\.\d+)?)\"") +_XML_FILENAME_RE = re.compile(r"""<[^>]+\bfilename=(?P["'])(?P.*?)(?P=quote)""") +_NONE_LIST_ITEM = "- None" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Assert minimum coverage for all declared components.") + parser.add_argument("--xml", action="append", default=[], help="Coverage XML input: name=path") + parser.add_argument("--lcov", action="append", default=[], help="LCOV input: name=path") + parser.add_argument( + "--require-source", + action="append", + default=[], + help="Workspace-relative file or directory that must appear in the coverage inputs.", + ) + parser.add_argument( + "--min-percent", + type=float, + default=100.0, + help="Minimum required coverage percentage for each component and combined summary.", + ) + parser.add_argument("--out-json", default="coverage-100/coverage.json", help="Output JSON path") + parser.add_argument("--out-md", default="coverage-100/coverage.md", help="Output markdown path") + return parser.parse_args() + + +def parse_named_path(value: str) -> Tuple[str, Path]: + match = _PAIR_RE.match(value.strip()) + if not match: + raise ValueError(f"Invalid input '{value}'. Expected format: name=path") + name = match.group("name").strip() + raw_path = match.group("path").strip() + candidate = SAFE_INPUT_FILE_PATH_IN_WORKSPACE(raw_path) + return name, candidate + + +def parse_coverage_xml(name: str, path: Path) -> CoverageStats: + text = path.read_text(encoding="utf-8") # lgtm [py/path-injection] + lines_valid_match = _XML_LINES_VALID_RE.search(text) + lines_covered_match = _XML_LINES_COVERED_RE.search(text) + + if lines_valid_match and lines_covered_match: + total = int(float(lines_valid_match.group(1))) + covered = int(float(lines_covered_match.group(1))) + return CoverageStats(name=name, path=str(path), covered=covered, total=total) + + total = 0 + covered = 0 + for hits_raw in _XML_LINE_HITS_RE.findall(text): + total += 1 + if int(float(hits_raw)) > 0: + covered += 1 + + return CoverageStats(name=name, path=str(path), covered=covered, total=total) + + +def _normalize_source_path(raw_path: str) -> str: + text = posixpath.normpath(raw_path.strip().replace("\\", "/")) + if not text: + return "" + if text == ".": + return "" + + workspace_root = posixpath.normpath(Path.cwd().resolve(strict=False).as_posix()) + if text == workspace_root: + return "" + if text.startswith(f"{workspace_root}/"): + return text[len(workspace_root) + 1 :] + return text + + +def normalize_source_path(raw_path: str) -> str: + """Return a workspace-relative normalized source path when possible.""" + return _normalize_source_path(raw_path) + + +def coverage_sources_from_xml(path: Path) -> Set[str]: + text = path.read_text(encoding="utf-8") # lgtm [py/path-injection] + covered_sources: Set[str] = set() + for match in _XML_FILENAME_RE.finditer(text): + filename = _normalize_source_path(match.group("value")) + if filename: + covered_sources.add(filename) + return covered_sources + + +def parse_lcov(name: str, path: Path) -> CoverageStats: + total = 0 + covered = 0 + + for raw in path.read_text(encoding="utf-8").splitlines(): # lgtm [py/path-injection] + line = raw.strip() + if line.startswith("LF:"): + total += int(line.split(":", 1)[1]) + elif line.startswith("LH:"): + covered += int(line.split(":", 1)[1]) + + return CoverageStats(name=name, path=str(path), covered=covered, total=total) + + +def coverage_sources_from_lcov(path: Path) -> Set[str]: + covered_sources: Set[str] = set() + for raw in path.read_text(encoding="utf-8").splitlines(): # lgtm [py/path-injection] + line = raw.strip() + if not line.startswith("SF:"): + continue + filename = _normalize_source_path(line.split(":", 1)[1]) + if filename: + covered_sources.add(filename) + return covered_sources + + +def _matches_required_source(source_path: str, required_source: str) -> bool: + normalized_required = _normalize_source_path(required_source).rstrip("/") + if not normalized_required: + return False + return source_path == normalized_required or source_path.startswith(f"{normalized_required}/") + + +def _find_missing_required_sources(reported_sources: Set[str], required_sources: List[str]) -> List[str]: + missing: List[str] = [] + for required_source in required_sources: + normalized_required = _normalize_source_path(required_source).rstrip("/") + if not normalized_required: + continue + if any(_matches_required_source(source_path, normalized_required) for source_path in reported_sources): + continue + missing.append(normalized_required) + return missing + + +def _is_tests_only_report(reported_sources: Set[str]) -> bool: + return bool(reported_sources) and all( + source_path == "tests" or source_path.startswith("tests/") for source_path in reported_sources + ) + + +def _coverage_findings(stats: List[CoverageStats], min_percent: float) -> List[str]: + findings: List[str] = [] + for item in stats: + if item.percent < min_percent: + findings.append( + f"{item.name} coverage below {min_percent:.2f}%: {item.percent:.2f}% ({item.covered}/{item.total})" + ) + + combined_total = sum(item.total for item in stats) + combined_covered = sum(item.covered for item in stats) + combined = 100.0 if combined_total <= 0 else (combined_covered / combined_total) * 100.0 + if combined < min_percent: + findings.append( + f"combined coverage below {min_percent:.2f}%: {combined:.2f}% ({combined_covered}/{combined_total})" + ) + return findings + + +def _source_findings(reported_sources: Set[str], required_sources: List[str]) -> List[str]: + findings: List[str] = [] + if _is_tests_only_report(reported_sources): + findings.append("coverage inputs only reference tests/ paths; first-party sources are missing.") + + for required_source in _find_missing_required_sources(reported_sources, required_sources): + findings.append(f"missing required source path: {required_source}") + return findings + + +def evaluate( + stats: List[CoverageStats], + min_percent: float, + *, + required_sources: Optional[List[str]] = None, + reported_sources: Optional[Set[str]] = None, +) -> Tuple[str, List[str]]: + normalized_sources = reported_sources or set() + findings = _coverage_findings(stats, min_percent) + findings.extend(_source_findings(normalized_sources, required_sources or [])) + status = "pass" if not findings else "fail" + return status, findings + + +def _append_component_lines(lines: List[str], payload: Dict[str, Any]) -> None: + components = payload.get("components") or [] + if components: + for item in components: + lines.append( + f"- `{item['name']}`: `{item['percent']:.2f}%` ({item['covered']}/{item['total']}) from `{item['path']}`" + ) + return + lines.append(_NONE_LIST_ITEM) + + +def _append_covered_source_lines(lines: List[str], payload: Dict[str, Any]) -> None: + sources = payload.get("covered_sources") or [] + if sources: + lines.extend(f"- `{source_path}`" for source_path in sources) + return + lines.append(_NONE_LIST_ITEM) + + +def _append_finding_lines(lines: List[str], payload: Dict[str, Any]) -> None: + findings = payload.get("findings") or [] + if findings: + lines.extend(f"- {finding}" for finding in findings) + return + lines.append(_NONE_LIST_ITEM) + + +def _render_md(payload: Dict[str, Any]) -> str: + lines = [ + "# Coverage 100 Gate", + "", + f"- Status: `{payload['status']}`", + f"- Minimum required coverage: `{payload['min_percent']:.2f}%`", + f"- Timestamp (UTC): `{payload['timestamp_utc']}`", + "", + "## Components", + ] + _append_component_lines(lines, payload) + + lines.extend(["", "## Covered sources"]) + _append_covered_source_lines(lines, payload) + + lines.extend(["", "## Findings"]) + _append_finding_lines(lines, payload) + + return "\n".join(lines) + "\n" + + +def _build_payload(stats: List[CoverageStats], covered_sources: Set[str], min_percent: float, findings: List[str], status: str) -> Dict[str, Any]: + return { + "status": status, + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "min_percent": min_percent, + "components": [ + { + "name": item.name, + "path": item.path, + "covered": item.covered, + "total": item.total, + "percent": item.percent, + } + for item in stats + ], + "covered_sources": sorted(covered_sources), + "findings": findings, + } + + +def _write_outputs(payload: Dict[str, Any], *, out_json: Path, out_md: Path) -> str: + os.makedirs(out_json.parent, exist_ok=True) + os.makedirs(out_md.parent, exist_ok=True) + with open(out_json, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + rendered = _render_md(payload) + with open(out_md, "w", encoding="utf-8") as handle: + handle.write(rendered) + print(rendered, end="") + return rendered + + +def main() -> int: + args = _parse_args() + + stats: List[CoverageStats] = [] + covered_sources: Set[str] = set() + for item in args.xml: + name, path = parse_named_path(item) + stats.append(parse_coverage_xml(name, path)) + covered_sources.update(coverage_sources_from_xml(path)) + for item in args.lcov: + name, path = parse_named_path(item) + stats.append(parse_lcov(name, path)) + covered_sources.update(coverage_sources_from_lcov(path)) + + if not stats: + raise SystemExit("No coverage files were provided; pass --xml and/or --lcov inputs.") + + min_percent = max(0.0, min(100.0, float(args.min_percent))) + status, findings = evaluate( + stats, + min_percent, + required_sources=list(args.require_source), + reported_sources=covered_sources, + ) + payload = _build_payload(stats, covered_sources, min_percent, findings, status) + + try: + out_json = SAFE_OUTPUT_PATH_IN_WORKSPACE(args.out_json, "coverage-100/coverage.json") + out_md = SAFE_OUTPUT_PATH_IN_WORKSPACE(args.out_md, "coverage-100/coverage.md") + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + _write_outputs(payload, out_json=out_json, out_md=out_md) + + return 0 if status == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/quality/check_codacy_zero.py b/scripts/quality/check_codacy_zero.py index 9667a4a..dfb3b59 100644 --- a/scripts/quality/check_codacy_zero.py +++ b/scripts/quality/check_codacy_zero.py @@ -1,323 +1,103 @@ -#!/usr/bin/env python3 -from __future__ import absolute_import, division - -import argparse -import importlib -import json -import os -import sys -import urllib.error -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Callable, Dict, List, Tuple, cast - -TOTAL_KEYS = ("total", "totalItems", "total_items", "count", "hits", "open_issues") -CODACY_API_HOST = "api.codacy.com" -CODACY_REQUEST_EXCEPTIONS = (urllib.error.URLError, ValueError, TypeError, RuntimeError) - -RequestJsonHttps = Callable[..., Tuple[Any, Dict[str, str]]] -EncodeIdentifier = Callable[..., str] -SafeOutputPathInWorkspace = Callable[..., Path] - - -def _load_security_imports() -> Any: - try: - return importlib.import_module("scripts.quality._security_imports") - except ModuleNotFoundError: # pragma: no cover - direct script execution - helper_root = Path(__file__).resolve().parent - helper_root_str = str(helper_root) - if helper_root_str not in sys.path: - sys.path.insert(0, helper_root_str) - return importlib.import_module("_security_imports") - - -_security_imports = _load_security_imports() -encode_identifier = cast(EncodeIdentifier, _security_imports.encode_identifier) -request_json_https = cast(RequestJsonHttps, _security_imports.request_json_https) -safe_output_path_in_workspace = cast( - SafeOutputPathInWorkspace, - _security_imports.safe_output_path_in_workspace, -) - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Assert Codacy has zero total open issues.") - parser.add_argument("--provider", default="gh", help="Organization provider, for example gh") - parser.add_argument("--owner", required=True, help="Repository owner") - parser.add_argument("--repo", required=True, help="Repository name") - parser.add_argument("--branch", default="", help="Optional branch name to scope issue totals") - parser.add_argument("--token", default="", help="Codacy API token (falls back to CODACY_API_TOKEN env)") - parser.add_argument("--out-json", default="codacy-zero/codacy.json", help="Output JSON path") - parser.add_argument("--out-md", default="codacy-zero/codacy.md", help="Output markdown path") - return parser.parse_args() - - -def _request_json( - *, - provider: str, - owner: str, - repo: str, - token: str, - branch: str = "", - limit: int = 1, - method: str = "GET", - data: Dict[str, Any] | None = None, -) -> Dict[str, Any]: - headers = { - "Accept": "application/json", - "api-token": token, - "User-Agent": "reframe-codacy-zero-gate", - } - if data is not None: - headers["Content-Type"] = "application/json" - - provider_slug = encode_identifier(provider, field_name="Codacy provider") - owner_slug = encode_identifier(owner, field_name="Codacy owner") - repo_slug = encode_identifier(repo, field_name="Codacy repository") - - payload_data: Dict[str, Any] = data or {} - branch_name = str(branch or "").strip() - if branch_name: - payload_data = {**payload_data, "branchName": branch_name} - - payload, _headers = request_json_https( - host=CODACY_API_HOST, - path=f"/api/v3/analysis/organizations/{provider_slug}/{owner_slug}/repositories/{repo_slug}/issues/search", - headers=headers, - method=method, - query={"limit": str(max(limit, 1))}, - data=payload_data, - ) - if not isinstance(payload, dict): - raise RuntimeError("Unexpected Codacy response payload.") - return payload - - -def _extract_numeric_total(payload: dict, keys: tuple) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, (int, float)): - return int(value) - return None - - -def extract_total_open(payload: Any) -> int | None: - if not isinstance(payload, dict): - return None - - pagination = payload.get("pagination") - if isinstance(pagination, dict): - total = _extract_numeric_total(pagination, ("total", "totalItems", "count")) - if total is not None: - return total - - stack: List[Any] = [payload] - while stack: - node = stack.pop() - if isinstance(node, dict): - total = _extract_numeric_total(node, TOTAL_KEYS) - if total is not None: - return total - stack.extend(node.values()) - continue - if isinstance(node, list): - stack.extend(node) - - return None - - -def _provider_candidates(preferred: str) -> List[str]: - values = [preferred, "gh", "github"] - return list(dict.fromkeys(item for item in values if item)) - - -def _first_text(issue: Dict[str, Any], keys: Tuple[str, ...]) -> str: - for key in keys: - value = str(issue.get(key) or "").strip() - if value: - return value - return "" - - -def _format_issue_sample(issue: dict) -> str | None: - pattern = _first_text(issue, ("patternId", "pattern")) - path = _first_text(issue, ("filename", "filePath", "path")) - message = _first_text(issue, ("message", "title")) - if not (pattern or path or message): - return None - - identity = pattern or "pattern:unknown" - location = path or "file:unknown" - suffix = f" - {message}" if message else "" - return f"Sample issue: `{identity}` at `{location}`{suffix}" - - -def _sample_issue_findings(payload: dict, limit: int = 5) -> List[str]: - data = payload.get("data") - if not isinstance(data, list): - return [] - - findings: List[str] = [] - for item in data: - if not isinstance(item, dict): - continue - sample = _format_issue_sample(item) - if not sample: - continue - findings.append(sample) - if len(findings) >= limit: - break - return findings - - -def _fetch_open_issues_for_provider( - *, - provider: str, - owner: str, - repo: str, - token: str, - branch: str, -) -> Tuple[bool, int | None, List[str], Exception | None]: - findings: List[str] = [] - open_issues: int | None = None - - try: - payload = _request_json( - provider=provider, - owner=owner, - repo=repo, - token=token, - branch=branch, - limit=1, - method="POST", - data={}, - ) - open_issues = extract_total_open(payload) - except urllib.error.HTTPError as exc: - if exc.code == 404: - return False, None, [], exc - return True, None, [f"Codacy API request failed: HTTP {exc.code}"], exc - except CODACY_REQUEST_EXCEPTIONS as exc: # pragma: no cover - network/runtime surface - return True, None, [f"Codacy API request failed: {exc}"], exc - - if open_issues is None: - findings.append("Codacy response did not include a parseable total issue count.") - return True, open_issues, findings, None - - if open_issues == 0: - return True, open_issues, findings, None - - findings.append(f"Codacy reports {open_issues} open issues (expected 0).") - sample_payload = _request_json( - provider=provider, - owner=owner, - repo=repo, - token=token, - branch=branch, - limit=20, - method="POST", - data={}, - ) - findings.extend(_sample_issue_findings(sample_payload)) - return True, open_issues, findings, None - - -def _query_open_issues( - *, - provider: str, - owner: str, - repo: str, - token: str, - branch: str, -) -> Tuple[int | None, List[str]]: - last_exc: Exception | None = None - - for candidate in _provider_candidates(provider): - handled, open_issues, findings, error = _fetch_open_issues_for_provider( - provider=candidate, - owner=owner, - repo=repo, - token=token, - branch=branch, - ) - if handled: - return open_issues, findings - last_exc = error - - findings = [ - f"Codacy API endpoint was not found for provider(s): {', '.join(_provider_candidates(provider))}." - ] - if last_exc is not None: - findings.append(f"Last Codacy API error: {last_exc}") - return None, findings - - -def _render_md(payload: dict) -> str: - lines = [ - "# Codacy Zero Gate", - "", - f"- Status: `{payload['status']}`", - f"- Owner/repo: `{payload['owner']}/{payload['repo']}`", - f"- Branch: `{payload.get('branch') or 'default'}`", - f"- Open issues: `{payload.get('open_issues')}`", - f"- Timestamp (UTC): `{payload['timestamp_utc']}`", - "", - "## Findings", - ] - findings = payload.get("findings") or [] - if findings: - lines.extend(f"- {item}" for item in findings) - else: - lines.append("- None") - return "\n".join(lines) + "\n" - - -def main() -> int: - args = _parse_args() - branch = getattr(args, "branch", "") - token = (args.token or os.environ.get("CODACY_API_TOKEN", "")).strip() - findings: List[str] = [] - open_issues: int | None = None - - if not token: - findings.append("CODACY_API_TOKEN is missing.") - else: - open_issues, findings = _query_open_issues( - provider=args.provider, - owner=args.owner.strip(), - repo=args.repo.strip(), - token=token, - branch=branch, - ) - - status = "pass" if not findings else "fail" - payload = { - "status": status, - "owner": args.owner, - "repo": args.repo, - "provider": args.provider, - "branch": branch, - "open_issues": open_issues, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "findings": findings, - } - - try: - out_json = safe_output_path_in_workspace(args.out_json, "codacy-zero/codacy.json") - out_md = safe_output_path_in_workspace(args.out_md, "codacy-zero/codacy.md") - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - out_json.parent.mkdir(parents=True, exist_ok=True) - out_md.parent.mkdir(parents=True, exist_ok=True) - out_json.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - out_md.write_text(_render_md(payload), encoding="utf-8") - print(out_md.read_text(encoding="utf-8"), end="") - return 0 if status == "pass" else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) - - +#!/usr/bin/env python3 +from __future__ import absolute_import, division + +import importlib +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _load_impl() -> Any: + try: + return importlib.import_module("scripts.quality._codacy_zero_impl") + except ModuleNotFoundError: # pragma: no cover - direct script execution + helper_root = Path(__file__).resolve().parent + helper_root_str = str(helper_root) + if helper_root_str not in sys.path: + sys.path.insert(0, helper_root_str) + return importlib.import_module("_codacy_zero_impl") + + +def _load_support() -> Any: + try: + return importlib.import_module("scripts.quality._codacy_zero_support") + except ModuleNotFoundError: # pragma: no cover - direct script execution + helper_root = Path(__file__).resolve().parent + helper_root_str = str(helper_root) + if helper_root_str not in sys.path: + sys.path.insert(0, helper_root_str) + return importlib.import_module("_codacy_zero_support") + + +_impl = _load_impl() +_support = _load_support() +CodacyRequest = _impl.CodacyRequest +TOTAL_KEYS = _impl.TOTAL_KEYS +CODACY_API_HOST = _impl.CODACY_API_HOST +CODACY_REQUEST_EXCEPTIONS = _impl.CODACY_REQUEST_EXCEPTIONS +request_json_https = _impl.request_json_https +encode_identifier = _impl.encode_identifier +safe_output_path_in_workspace = _impl.safe_output_path_in_workspace + +_parse_args = _impl._parse_args +_request_json = _impl._request_json +_extract_numeric_total = _impl._extract_numeric_total +extract_total_open = _impl.extract_total_open +_provider_candidates = _impl._provider_candidates +_first_text = _support._first_text +_format_issue_sample = _support._format_issue_sample +_sample_issue_findings = _support._sample_issue_findings +_fetch_open_issues_for_provider = _impl._fetch_open_issues_for_provider +_query_open_issues = _impl._query_open_issues +_render_md = _impl._render_md + + +def main() -> int: + args = _parse_args() + branch = getattr(args, "branch", "") + token = (args.token or os.environ.get("CODACY_API_TOKEN", "")).strip() + findings: list[str] = [] + open_issues: int | None = None + + if not token: + findings.append("CODACY_API_TOKEN is missing.") + else: + open_issues, findings = _query_open_issues( + provider=args.provider, + owner=args.owner.strip(), + repo=args.repo.strip(), + token=token, + branch=branch, + ) + + status = "pass" if not findings else "fail" + payload = { + "status": status, + "owner": args.owner, + "repo": args.repo, + "provider": args.provider, + "branch": branch, + "open_issues": open_issues, + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "findings": findings, + } + + try: + out_json = safe_output_path_in_workspace(args.out_json, "codacy-zero/codacy.json") + out_md = safe_output_path_in_workspace(args.out_md, "codacy-zero/codacy.md") + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + out_json.parent.mkdir(parents=True, exist_ok=True) + out_md.parent.mkdir(parents=True, exist_ok=True) + out_json.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + out_md.write_text(_render_md(payload), encoding="utf-8") + return 0 if status == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/quality/check_deepscan_zero.py b/scripts/quality/check_deepscan_zero.py index 2963b77..780c75f 100644 --- a/scripts/quality/check_deepscan_zero.py +++ b/scripts/quality/check_deepscan_zero.py @@ -7,6 +7,7 @@ import os import sys import urllib.error +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, List, Tuple, cast @@ -18,6 +19,15 @@ SplitValidatedHttpsUrl = Callable[..., Tuple[str, str, Dict[str, str]]] +@dataclass(frozen=True) +class DeepScanRequest: + host: str + path: str + query: dict + token: str + findings: List[str] + + def _load_security_imports() -> Any: try: return importlib.import_module("scripts.quality._security_imports") @@ -88,26 +98,39 @@ def _resolve_deepscan_endpoint(open_issues_url: str) -> Tuple[str, str, Dict[str ) -def _fetch_open_issues( - *, - host: str, - path: str, - query: dict, - token: str, - findings: List[str], -) -> int | None: +def _coerce_fetch_request(*args: Any, **kwargs: Any) -> DeepScanRequest: + if args: + if len(args) == 1 and isinstance(args[0], DeepScanRequest): + if kwargs: + raise TypeError("Pass either a request object or keyword arguments, not both.") + return args[0] + if len(args) == 5 and not kwargs: + host, path, query, token, findings = args + return DeepScanRequest(host=str(host), path=str(path), query=dict(query), token=str(token), findings=findings) + raise TypeError("Pass a request object or keyword arguments, not positional arguments.") + return DeepScanRequest( + host=str(kwargs.pop("host")), + path=str(kwargs.pop("path")), + query=dict(kwargs.pop("query")), + token=str(kwargs.pop("token")), + findings=kwargs.pop("findings"), + ) + + +def _fetch_open_issues(*args: Any, **kwargs: Any) -> int | None: + request = _coerce_fetch_request(*args, **kwargs) try: - payload = _request_json(host=host, path=path, query=query, token=token) + payload = _request_json(host=request.host, path=request.path, query=request.query, token=request.token) except (urllib.error.URLError, RuntimeError, ValueError) as exc: # pragma: no cover - network/runtime surface - findings.append(f"DeepScan API request failed: {exc}") + request.findings.append(f"DeepScan API request failed: {exc}") return None open_issues = extract_total_open(payload) if open_issues is None: - findings.append("DeepScan response did not include a parseable total issue count.") + request.findings.append("DeepScan response did not include a parseable total issue count.") return None if open_issues != 0: - findings.append(f"DeepScan reports {open_issues} open issues (expected 0).") + request.findings.append(f"DeepScan reports {open_issues} open issues (expected 0).") return open_issues diff --git a/scripts/quality/check_required_checks.py b/scripts/quality/check_required_checks.py index 70a2789..1237997 100644 --- a/scripts/quality/check_required_checks.py +++ b/scripts/quality/check_required_checks.py @@ -2,23 +2,37 @@ from __future__ import absolute_import, division import argparse +import importlib import json import os -import re import sys import time import urllib.error from datetime import datetime, timezone +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -try: - from ._security_imports import encode_identifier, request_json_https, safe_output_path_in_workspace -except ImportError: # pragma: no cover - direct script execution - from _security_imports import encode_identifier, request_json_https, safe_output_path_in_workspace -GITHUB_API_HOST = "api.github.com" -_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,64}$") -_TRANSIENT_HTTP_CODES = {429, 500, 502, 503, 504} +def _load_impl() -> Any: + try: + return importlib.import_module("scripts.quality._required_checks_impl") + except ModuleNotFoundError: # pragma: no cover - direct script execution + helper_root = Path(__file__).resolve().parent + helper_root_str = str(helper_root) + if helper_root_str not in sys.path: + sys.path.insert(0, helper_root_str) + return importlib.import_module("_required_checks_impl") + + +_impl = _load_impl() +GitHubRequest = _impl.GitHubRequest +SettledChecksRequest = _impl.SettledChecksRequest +GITHUB_API_HOST = _impl.GITHUB_API_HOST +_SHA_RE = _impl._SHA_RE +_TRANSIENT_HTTP_CODES = _impl._TRANSIENT_HTTP_CODES +encode_identifier = _impl.encode_identifier +request_json_https = _impl.request_json_https +safe_output_path_in_workspace = _impl.safe_output_path_in_workspace def _parse_args() -> argparse.Namespace: @@ -34,295 +48,95 @@ def _parse_args() -> argparse.Namespace: def _parse_repo(raw: str) -> Tuple[str, str]: - text = (raw or "").strip() - if "/" not in text: - raise ValueError("Repo must be in owner/repo format.") - owner, repo = text.split("/", 1) - return ( - encode_identifier(owner, field_name="GitHub owner"), - encode_identifier(repo, field_name="GitHub repo"), - ) + return _impl._parse_repo(raw) def _parse_sha(raw: str) -> str: - sha = (raw or "").strip() - if not _SHA_RE.fullmatch(sha): - raise ValueError("Commit SHA must be a 7-64 char hex string.") - return sha.lower() + return _impl._parse_sha(raw) def _github_headers(token: str) -> Dict[str, str]: - return { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "reframe-quality-zero-gate", - } + return _impl._github_headers(token) def _is_transient_http_error(exc: urllib.error.HTTPError) -> bool: - return int(exc.code) in _TRANSIENT_HTTP_CODES + return _impl._is_transient_http_error(exc) def _should_retry_http_error(*, exc: urllib.error.HTTPError, attempt: int, attempts: int) -> bool: - return _is_transient_http_error(exc) and attempt < attempts + return _impl._should_retry_http_error(exc=exc, attempt=attempt, attempts=attempts) def _should_retry_url_error(*, attempt: int, attempts: int) -> bool: - return attempt < attempts + return _impl._should_retry_url_error(attempt=attempt, attempts=attempts) def _next_retry_wait(wait_seconds: int) -> int: - return min(wait_seconds * 2, 10) - - -def _request_payload_with_retry( - *, - owner: str, - repo: str, - sha: str, - token: str, - endpoint: str, - query: Optional[Dict[str, str]] = None, - attempts: int = 5, -) -> Dict[str, Any]: - wait_seconds = 1 - last_error: Optional[Exception] = None - total_attempts = max(attempts, 1) - - for attempt in range(1, total_attempts + 1): - try: - payload, _headers = request_json_https( - host=GITHUB_API_HOST, - path=f"/repos/{owner}/{repo}/commits/{sha}/{endpoint}", - headers={**_github_headers(token)}, - query=query, - method="GET", - ) - if not isinstance(payload, dict): - raise RuntimeError(f"Unexpected GitHub {endpoint} response payload.") - return payload - except urllib.error.HTTPError as exc: - last_error = exc - if not _should_retry_http_error(exc=exc, attempt=attempt, attempts=total_attempts): - raise - except urllib.error.URLError as exc: - last_error = exc - if not _should_retry_url_error(attempt=attempt, attempts=total_attempts): - raise - - time.sleep(wait_seconds) - wait_seconds = _next_retry_wait(wait_seconds) - - if last_error is None: - raise RuntimeError(f"Failed to query GitHub endpoint: {endpoint}") - raise RuntimeError(f"Failed to query GitHub endpoint: {endpoint}") from last_error + return _impl._next_retry_wait(wait_seconds) + + +def _request_payload_with_retry(request: GitHubRequest) -> Dict[str, Any]: + return _impl._request_payload_with_retry(request) def _api_get_check_runs(*, owner: str, repo: str, sha: str, token: str) -> Dict[str, Any]: - return _request_payload_with_retry( - owner=owner, - repo=repo, - sha=sha, - token=token, - endpoint="check-runs", - query={"per_page": "100"}, - ) + return _impl._api_get_check_runs(owner=owner, repo=repo, sha=sha, token=token) def _api_get_status(*, owner: str, repo: str, sha: str, token: str) -> Dict[str, Any]: - return _request_payload_with_retry( - owner=owner, - repo=repo, - sha=sha, - token=token, - endpoint="status", - ) + return _impl._api_get_status(owner=owner, repo=repo, sha=sha, token=token) def _check_run_context(run: Dict[str, Any]) -> Optional[Tuple[str, Dict[str, str]]]: - name = str(run.get("name") or "").strip() - if not name: - return None - return name, { - "state": str(run.get("status") or ""), - "conclusion": str(run.get("conclusion") or ""), - "source": "check_run", - } + return _impl._check_run_context(run) def _status_context(status: Dict[str, Any]) -> Optional[Tuple[str, Dict[str, str]]]: - name = str(status.get("context") or "").strip() - if not name: - return None - state = str(status.get("state") or "") - return name, { - "state": state, - "conclusion": state, - "source": "status", - } + return _impl._status_context(status) def _collect_contexts(check_runs_payload: Dict[str, Any], status_payload: Dict[str, Any]) -> Dict[str, Dict[str, str]]: - contexts: Dict[str, Dict[str, str]] = {} - - for run in check_runs_payload.get("check_runs", []) or []: - entry = _check_run_context(run) - if entry: - key, value = entry - contexts[key] = value - - for status in status_payload.get("statuses", []) or []: - entry = _status_context(status) - if entry: - key, value = entry - contexts[key] = value - - return contexts + return _impl._collect_contexts(check_runs_payload, status_payload) def _check_run_failure(context: str, observed: Dict[str, str]) -> Optional[str]: - state = observed.get("state") - if state != "completed": - return f"{context}: status={state}" - - conclusion = observed.get("conclusion") - if conclusion != "success": - return f"{context}: conclusion={conclusion}" - return None + return _impl._check_run_failure(context, observed) def _status_failure(context: str, observed: Dict[str, str]) -> Optional[str]: - conclusion = observed.get("conclusion") - if conclusion != "success": - return f"{context}: state={conclusion}" - return None + return _impl._status_failure(context, observed) def _evaluate(required: List[str], contexts: Dict[str, Dict[str, str]]) -> Tuple[str, List[str], List[str]]: - missing: List[str] = [] - failed: List[str] = [] - - for context in required: - observed = contexts.get(context) - if not observed: - missing.append(context) - continue - - if observed.get("source") == "check_run": - failure = _check_run_failure(context, observed) - else: - failure = _status_failure(context, observed) - if failure: - failed.append(failure) - - status = "pass" if not missing and not failed else "fail" - return status, missing, failed + return _impl._evaluate(required, contexts) def _render_md(payload: Dict[str, Any]) -> str: - lines = [ - "# Quality Zero Gate - Required Contexts", - "", - f"- Status: `{payload['status']}`", - f"- Repo/SHA: `{payload['repo']}@{payload['sha']}`", - f"- Timestamp (UTC): `{payload['timestamp_utc']}`", - "", - "## Missing contexts", - ] - - missing = payload.get("missing") or [] - if missing: - lines.extend(f"- `{name}`" for name in missing) - else: - lines.append("- None") - - lines.extend(["", "## Failed contexts"]) - failed = payload.get("failed") or [] - if failed: - lines.extend(f"- {entry}" for entry in failed) - else: - lines.append("- None") - - return "\n".join(lines) + "\n" + return _impl._render_md(payload) def _required_contexts(args: argparse.Namespace) -> List[str]: - required = [item.strip() for item in args.required_context if item.strip()] - if not required: - raise SystemExit("At least one --required-context is required") - return required + return _impl._required_contexts(args) def _github_token() -> str: - token = (os.environ.get("GITHUB_TOKEN", "") or os.environ.get("GH_TOKEN", "")).strip() - if not token: - raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required") - return token - - -def _snapshot( - *, - repo_arg: str, - sha: str, - required: List[str], - contexts: Dict[str, Dict[str, str]], -) -> Dict[str, Any]: - status, missing, failed = _evaluate(required, contexts) - return { - "status": status, - "repo": repo_arg, - "sha": sha, - "required": required, - "missing": missing, - "failed": failed, - "contexts": contexts, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - } + return _impl._github_token() + + +def _snapshot(*args, **kwargs) -> Dict[str, Any]: + return _impl._snapshot(*args, **kwargs) def _has_in_progress_check_run(contexts: Dict[str, Dict[str, str]]) -> bool: - for observed in contexts.values(): - if observed.get("source") == "check_run" and observed.get("state") != "completed": - return True - return False + return _impl._has_in_progress_check_run(contexts) def _should_wait(payload: Dict[str, Any]) -> bool: - if payload["status"] == "pass": - return False - if payload["missing"]: - return True - return _has_in_progress_check_run(payload["contexts"]) - - -def _collect_until_settled( - *, - owner_slug: str, - repo_slug: str, - repo_arg: str, - sha: str, - token: str, - required: List[str], - timeout_seconds: int, - poll_seconds: int, -) -> Dict[str, Any]: - deadline = time.time() + max(timeout_seconds, 1) - final_payload: Optional[Dict[str, Any]] = None - - while time.time() <= deadline: - check_runs = _api_get_check_runs(owner=owner_slug, repo=repo_slug, sha=sha, token=token) - statuses = _api_get_status(owner=owner_slug, repo=repo_slug, sha=sha, token=token) - contexts = _collect_contexts(check_runs, statuses) - - final_payload = _snapshot(repo_arg=repo_arg, sha=sha, required=required, contexts=contexts) - if not _should_wait(final_payload): - break - time.sleep(max(poll_seconds, 1)) - - if final_payload is None: - raise SystemExit("No payload collected") - return final_payload + return _impl._should_wait(payload) + + +def _collect_until_settled(request: SettledChecksRequest) -> Dict[str, Any]: + return _impl._collect_until_settled(request) def main() -> int: @@ -337,14 +151,16 @@ def main() -> int: raise SystemExit(str(exc)) from exc final_payload = _collect_until_settled( - owner_slug=owner_slug, - repo_slug=repo_slug, - repo_arg=args.repo, - sha=sha, - token=token, - required=required, - timeout_seconds=args.timeout_seconds, - poll_seconds=args.poll_seconds, + SettledChecksRequest( + owner_slug=owner_slug, + repo_slug=repo_slug, + repo_arg=args.repo, + sha=sha, + token=token, + required=required, + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) ) try: @@ -365,4 +181,3 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/scripts/quality/check_sentry_zero.py b/scripts/quality/check_sentry_zero.py index 937dd82..8cc15ec 100644 --- a/scripts/quality/check_sentry_zero.py +++ b/scripts/quality/check_sentry_zero.py @@ -8,6 +8,7 @@ import os import sys import urllib.error +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Mapping, Tuple @@ -25,6 +26,13 @@ SENTRY_API_HOST = "sentry.io" +@dataclass(frozen=True) +class SentryScanRequest: + org: str + projects: List[str] + token: str + + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Assert Sentry has zero unresolved issues for configured projects.") parser.add_argument("--org", default="", help="Sentry org slug (falls back to SENTRY_ORG env)") @@ -93,26 +101,50 @@ def _missing_config_findings(token: str, org: str, projects: List[str]) -> List[ return findings -def _scan_projects( - org: str, - projects: List[str], - token: str, -) -> Tuple[str, List[Dict[str, Any]], List[str], List[str]]: +def _resolve_unresolved_count( + issues: List[Any], + headers: Dict[str, str], + project: str, + failures: List[str], +) -> int: + unresolved = _hits_from_headers(headers) + if unresolved is None: + unresolved = len(issues) + if unresolved >= 1: + failures.append( + f"Sentry project {project} returned unresolved issues but no X-Hits header for exact totals." + ) + return unresolved + + +def _coerce_scan_request(*args: Any, **kwargs: Any) -> SentryScanRequest: + if args: + if len(args) == 1 and isinstance(args[0], SentryScanRequest): + if kwargs: + raise TypeError("Pass either a request object or keyword arguments, not both.") + return args[0] + if len(args) == 3 and not kwargs: + org, projects, token = args + return SentryScanRequest(org=str(org), projects=list(projects), token=str(token)) + raise TypeError("Pass a request object or keyword arguments, not positional arguments.") + return SentryScanRequest( + org=str(kwargs.pop("org")), + projects=list(kwargs.pop("projects")), + token=str(kwargs.pop("token")), + ) + + +def _scan_projects(*args: Any, **kwargs: Any) -> Tuple[str, List[Dict[str, Any]], List[str], List[str]]: + request = _coerce_scan_request(*args, **kwargs) mode = "strict" project_results: List[Dict[str, Any]] = [] findings: List[str] = [] failures: List[str] = [] - for project in projects: + for project in request.projects: try: - issues, headers = _request_project_issues(org, project, token) - unresolved = _hits_from_headers(headers) - if unresolved is None: - unresolved = len(issues) - if unresolved >= 1: - failures.append( - f"Sentry project {project} returned unresolved issues but no X-Hits header for exact totals." - ) + issues, headers = _request_project_issues(request.org, project, request.token) + unresolved = _resolve_unresolved_count(issues, headers, project, failures) if unresolved != 0: failures.append(f"Sentry project {project} has {unresolved} unresolved issues (expected 0).") project_results.append({"project": project, "unresolved": unresolved}) @@ -174,7 +206,7 @@ def main() -> int: status = "pass" mode = "skipped" else: - mode, project_results, runtime_findings, failures = _scan_projects(org, projects, token) + mode, project_results, runtime_findings, failures = _scan_projects(org=org, projects=projects, token=token) findings.extend(runtime_findings) findings.extend(failures) status = "pass" if not failures else "fail" diff --git a/scripts/security_helpers.py b/scripts/security_helpers.py index 0f84756..b6a5530 100644 --- a/scripts/security_helpers.py +++ b/scripts/security_helpers.py @@ -8,6 +8,7 @@ import urllib.error import urllib.parse import urllib.request +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Optional, Set, Tuple from urllib.parse import urlparse, urlunparse @@ -17,6 +18,27 @@ _LOCAL_IP_FLAGS = ("is_private", "is_loopback", "is_link_local", "is_reserved", "is_multicast") +@dataclass(frozen=True) +class _HttpsRequestInput: + host: str + path: str + headers: Dict[str, str] + method: str = "GET" + query: Optional[Dict[str, str]] = None + data: Optional[Dict[str, Any]] = None + timeout: int = 30 + + +@dataclass(frozen=True) +class _HttpsExecutionRequest: + host: str + method: str + request_target: str + headers: Dict[str, str] + body: Optional[str] + timeout: int + + def _parse_https_url(raw_url: str): parsed = urlparse((raw_url or "").strip()) if parsed.scheme != "https": @@ -182,51 +204,58 @@ def _read_https_error(exc: urllib.error.HTTPError) -> Tuple[int, str, str, Dict[ return status, reason, raw_body, response_headers -def _execute_https_request( - *, - host: str, - method: str, - request_target: str, - headers: Dict[str, str], - body: Optional[str], - timeout: int, -) -> Tuple[int, str, str, Dict[str, str]]: - request = urllib.request.Request( - url=f"https://{host}{request_target}", - data=body.encode("utf-8") if body is not None else None, - headers=headers, - method=method.upper(), +def _execute_https_request(request: _HttpsExecutionRequest) -> Tuple[int, str, str, Dict[str, str]]: + http_request = urllib.request.Request( + url=f"https://{request.host}{request.request_target}", + data=request.body.encode("utf-8") if request.body is not None else None, + headers=request.headers, + method=request.method.upper(), ) try: opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=_secure_ssl_context())) - with opener.open(request, timeout=timeout) as response: + with opener.open(http_request, timeout=request.timeout) as response: status, reason, raw_body, response_headers = _read_https_success(response) except urllib.error.HTTPError as exc: status, reason, raw_body, response_headers = _read_https_error(exc) return status, reason, raw_body, response_headers -def request_json_https( - *, - host: str, - path: str, - headers: Dict[str, str], - method: str = "GET", - query: Optional[Dict[str, str]] = None, - data: Optional[Dict[str, Any]] = None, - timeout: int = 30, -) -> Tuple[Any, Dict[str, str]]: - validated_host = _normalize_https_host(host) - normalized_path = _normalize_https_path(path) - request_target = _build_request_target(normalized_path, query) - body = _json_body_or_none(data) +def _coerce_https_request(*args: Any, **kwargs: Any) -> _HttpsRequestInput: + if args: + if len(args) == 1 and isinstance(args[0], _HttpsRequestInput): + if kwargs: + raise TypeError("Pass either a request object or keyword arguments, not both.") + return args[0] + raise TypeError("Pass a request object or keyword arguments, not positional arguments.") + request = _HttpsRequestInput( + host=str(kwargs.pop("host")), + path=str(kwargs.pop("path")), + headers=dict(kwargs.pop("headers")), + method=str(kwargs.pop("method", "GET")), + query=kwargs.pop("query", None), + data=kwargs.pop("data", None), + timeout=int(kwargs.pop("timeout", 30)), + ) + if kwargs: + raise TypeError(f"Unexpected keyword arguments: {', '.join(sorted(kwargs))}") + return request + + +def request_json_https(*args: Any, **kwargs: Any) -> Tuple[Any, Dict[str, str]]: + request = _coerce_https_request(*args, **kwargs) + validated_host = _normalize_https_host(request.host) + normalized_path = _normalize_https_path(request.path) + request_target = _build_request_target(normalized_path, request.query) + body = _json_body_or_none(request.data) status, reason, raw_body, response_headers = _execute_https_request( - host=validated_host, - method=method, - request_target=request_target, - headers=headers, - body=body, - timeout=timeout, + _HttpsExecutionRequest( + host=validated_host, + method=request.method, + request_target=request_target, + headers=request.headers, + body=body, + timeout=request.timeout, + ) ) if status >= 400: error_headers = Message() diff --git a/tests/test_gui_table_logic.py b/tests/test_gui_table_logic.py index 46e53be..b2aa0fe 100644 --- a/tests/test_gui_table_logic.py +++ b/tests/test_gui_table_logic.py @@ -4,7 +4,7 @@ from env_inspector_core.models import EnvRecord from env_inspector_gui.models import SortState -from env_inspector_gui.table_logic import build_display_rows, sort_display_rows, toggle_sort +from env_inspector_gui.table_logic import DisplayRowsRequest, build_display_rows, sort_display_rows, toggle_sort from tests.assertions import ensure @@ -42,15 +42,17 @@ def _rec(name: str, value: str, **overrides: object) -> EnvRecord: def test_build_display_rows_filters_context_and_only_secrets(): rows = build_display_rows( - [ - _rec("PUBLIC", "abc", context="windows", is_secret=False), - _rec("TOKEN", "supersecretvalue", context="windows", is_secret=True), - _rec("WSL_SECRET", "anothersecret", context="wsl:Ubuntu", is_secret=True), - ], - context="windows", - query="", - only_secrets=True, - show_secrets=False, + DisplayRowsRequest( + records=[ + _rec("PUBLIC", "abc", context="windows", is_secret=False), + _rec("TOKEN", "supersecretvalue", context="windows", is_secret=True), + _rec("WSL_SECRET", "anothersecret", context="wsl:Ubuntu", is_secret=True), + ], + context="windows", + query="", + only_secrets=True, + show_secrets=False, + ) ) ensure([row.record.name for row in rows] == ["TOKEN"]) @@ -61,35 +63,41 @@ def test_hidden_secret_search_uses_masked_value_not_raw_secret(): record = _rec("API_TOKEN", "supersecretvalue", is_secret=True) hidden_rows = build_display_rows( - [record], - context="windows", - query="supersecretvalue", - only_secrets=False, - show_secrets=False, + DisplayRowsRequest( + records=[record], + context="windows", + query="supersecretvalue", + only_secrets=False, + show_secrets=False, + ) ) ensure(hidden_rows == []) shown_rows = build_display_rows( - [record], - context="windows", - query="supersecretvalue", - only_secrets=False, - show_secrets=True, + DisplayRowsRequest( + records=[record], + context="windows", + query="supersecretvalue", + only_secrets=False, + show_secrets=True, + ) ) ensure(len(shown_rows) == 1) def test_sort_toggle_and_stable_sort_behavior(): rows = build_display_rows( - [ - _rec("A", "v2", source_path="/workspace/2.env"), - _rec("A", "v1", source_path="/workspace/1.env"), - _rec("B", "v3", source_path="/workspace/3.env"), - ], - context="windows", - query="", - only_secrets=False, - show_secrets=True, + DisplayRowsRequest( + records=[ + _rec("A", "v2", source_path="/workspace/2.env"), + _rec("A", "v1", source_path="/workspace/1.env"), + _rec("B", "v3", source_path="/workspace/3.env"), + ], + context="windows", + query="", + only_secrets=False, + show_secrets=True, + ) ) state = SortState(column="name", descending=False) @@ -103,14 +111,16 @@ def test_sort_toggle_and_stable_sort_behavior(): def test_bool_sort_columns_use_yes_no_semantics(): rows = build_display_rows( - [ - _rec("A", "1", is_secret=True), - _rec("B", "2", is_secret=False), - ], - context="windows", - query="", - only_secrets=False, - show_secrets=True, + DisplayRowsRequest( + records=[ + _rec("A", "1", is_secret=True), + _rec("B", "2", is_secret=False), + ], + context="windows", + query="", + only_secrets=False, + show_secrets=True, + ) ) ordered = sort_display_rows(rows, SortState(column="secret", descending=False)) diff --git a/tests/test_linux_support.py b/tests/test_linux_support.py index 80687ab..a12d229 100644 --- a/tests/test_linux_support.py +++ b/tests/test_linux_support.py @@ -12,6 +12,23 @@ from tests.assertions import ensure +_ORIGINAL_PATH_EXISTS = service_module._path_exists +_ORIGINAL_READ_TEXT_IF_EXISTS = service_module._read_text_if_exists +_ORIGINAL_WRITE_TEXT_FILE = EnvInspectorService._write_text_file +_ORIGINAL_WHICH = service_module.which +_ORIGINAL_RUN = service_module.run +_ORIGINAL_PATH_HOME = service_module.Path.home + + +@pytest.fixture(autouse=True) +def _reset_service_globals(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service_module, "_path_exists", _ORIGINAL_PATH_EXISTS) + monkeypatch.setattr(service_module, "_read_text_if_exists", _ORIGINAL_READ_TEXT_IF_EXISTS) + monkeypatch.setattr(EnvInspectorService, "_write_text_file", staticmethod(_ORIGINAL_WRITE_TEXT_FILE)) + monkeypatch.setattr(service_module, "which", _ORIGINAL_WHICH) + monkeypatch.setattr(service_module, "run", _ORIGINAL_RUN) + monkeypatch.setattr(service_module.Path, "home", _ORIGINAL_PATH_HOME) + def _record(source_type: str, context: str, name: str, value: str, precedence: int) -> EnvRecord: return EnvRecord( source_type=source_type, diff --git a/tests/test_pr39_owned_coverage.py b/tests/test_pr39_owned_coverage.py index 4c22040..7c3ca34 100644 --- a/tests/test_pr39_owned_coverage.py +++ b/tests/test_pr39_owned_coverage.py @@ -14,6 +14,7 @@ import env_inspector_core.service_listing as service_listing_module import env_inspector_core.service_privileged as service_privileged_module from env_inspector_core.models import EnvRecord, OperationResult +from env_inspector_core.service_ops import OperationResultInput, operation_result from env_inspector_core.path_policy import PathPolicyError from env_inspector_core.service import EnvInspectorService from scripts.quality import assert_coverage_100 as coverage_mod @@ -155,29 +156,43 @@ def test_service_wrapper_owned_target_branches(tmp_path: Path, monkeypatch): monkeypatch.setattr(svc, "_registry_write", lambda *args, **kwargs: ("before", "after", "registry", False, None)) ensure( svc._plan_target_operation( - "windows:user", - "API_TOKEN", - "1", - "set", + service_module.TargetOperationRequest( + target="windows:user", + key="API_TOKEN", + value="1", + action="set", + scope_roots=[tmp_path], + ), apply_changes=False, - scope_roots=[tmp_path], )[2] == "registry" ) with pytest.raises(RuntimeError, match="Unsupported target"): - svc._file_update("custom:target", "API_TOKEN", "1", "set", apply_changes=False, scope_roots=[tmp_path]) + svc._file_update( + service_module.TargetOperationRequest( + target="custom:target", + key="API_TOKEN", + value="1", + action="set", + scope_roots=[tmp_path], + ), + apply_changes=False, + ) ensure( - svc._make_operation_result( - operation_id="op-1", - target="linux:bashrc", - action="set", - success=True, - backup_path=None, - diff_preview="", - error_message=None, - value_masked=None, + operation_result( + OperationResultInput( + operation_id="op-1", + target="linux:bashrc", + action="set", + success=True, + backup_path=None, + preview_only=False, + diff_preview="", + error_message=None, + value_masked=None, + ) ).success is True ) diff --git a/tests/test_qlty_config.py b/tests/test_qlty_config.py new file mode 100644 index 0000000..1fb2981 --- /dev/null +++ b/tests/test_qlty_config.py @@ -0,0 +1,15 @@ +from __future__ import absolute_import, division + +from pathlib import Path +import tomllib + + +def test_qlty_config_enables_blocking_smells() -> None: + config_path = Path(".qlty/qlty.toml") + assert config_path.exists() + + payload = tomllib.loads(config_path.read_text(encoding="utf-8")) + + assert payload["config_version"] == "0" + assert payload["smells"]["mode"] == "block" + assert any(source.get("default") for source in payload.get("source", [])) diff --git a/tests/test_quality_assert_coverage.py b/tests/test_quality_assert_coverage.py index 47eebce..cb23c5f 100644 --- a/tests/test_quality_assert_coverage.py +++ b/tests/test_quality_assert_coverage.py @@ -5,6 +5,7 @@ import pytest from scripts.quality import assert_coverage_100 as coverage_mod +from scripts.quality import _security_imports as security_imports from scripts import security_helpers as sec from tests.assertions import ensure @@ -68,11 +69,23 @@ def test_normalize_source_path_handles_empty_and_workspace_absolute_paths(tmp_pa inside_file = tmp_path / "env_inspector.py" inside_file.write_text("print('ok')\n", encoding="utf-8") - ensure(coverage_mod._normalize_source_path("") == "") - ensure(coverage_mod._normalize_source_path(str(tmp_path)) == "") - ensure(coverage_mod._normalize_source_path(str(inside_file)) == "env_inspector.py") + ensure(coverage_mod.normalize_source_path("") == "") + ensure(coverage_mod.normalize_source_path(str(tmp_path)) == "") + ensure(coverage_mod.normalize_source_path(str(inside_file)) == "env_inspector.py") def test_normalize_source_path_handles_empty_normpath_result(monkeypatch): monkeypatch.setattr(coverage_mod.posixpath, "normpath", lambda _value: "") - ensure(coverage_mod._normalize_source_path("ignored") == "") + ensure(coverage_mod.normalize_source_path("ignored") == "") + + +def test_assert_coverage_uses_shared_security_import_helpers(): + """Keep the coverage script aligned with the shared security import surface.""" + ensure( + coverage_mod.SAFE_INPUT_FILE_PATH_IN_WORKSPACE + is security_imports.safe_input_file_path_in_workspace + ) + ensure( + coverage_mod.SAFE_OUTPUT_PATH_IN_WORKSPACE + is security_imports.safe_output_path_in_workspace + ) diff --git a/tests/test_quality_codacy_deepscan_branches.py b/tests/test_quality_codacy_deepscan_branches.py index e92b2c1..3cac908 100644 --- a/tests/test_quality_codacy_deepscan_branches.py +++ b/tests/test_quality_codacy_deepscan_branches.py @@ -295,7 +295,7 @@ def test_deepscan_resolve_and_fetch_open_issues_paths(monkeypatch): def test_deepscan_fetch_open_issues_handles_unparseable_total(monkeypatch): - findings = [] + findings: List[str] = [] monkeypatch.setattr(deepscan_mod, "_request_json", lambda **_kwargs: {"meta": {"count": "n/a"}}) open_issues = deepscan_mod._fetch_open_issues( diff --git a/tests/test_service_preview.py b/tests/test_service_preview.py index 783b42c..a804f1f 100644 --- a/tests/test_service_preview.py +++ b/tests/test_service_preview.py @@ -1,10 +1,30 @@ from __future__ import absolute_import, division from pathlib import Path +import pytest + +import env_inspector_core.service as service_module from env_inspector_core.service import EnvInspectorService from tests.assertions import ensure +_ORIGINAL_PATH_EXISTS = service_module._path_exists +_ORIGINAL_READ_TEXT_IF_EXISTS = service_module._read_text_if_exists +_ORIGINAL_WRITE_TEXT_FILE = EnvInspectorService._write_text_file +_ORIGINAL_WHICH = service_module.which +_ORIGINAL_RUN = service_module.run +_ORIGINAL_PATH_HOME = service_module.Path.home + + +@pytest.fixture(autouse=True) +def _reset_service_globals(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service_module, "_path_exists", _ORIGINAL_PATH_EXISTS) + monkeypatch.setattr(service_module, "_read_text_if_exists", _ORIGINAL_READ_TEXT_IF_EXISTS) + monkeypatch.setattr(EnvInspectorService, "_write_text_file", staticmethod(_ORIGINAL_WRITE_TEXT_FILE)) + monkeypatch.setattr(service_module, "which", _ORIGINAL_WHICH) + monkeypatch.setattr(service_module, "run", _ORIGINAL_RUN) + monkeypatch.setattr(service_module.Path, "home", _ORIGINAL_PATH_HOME) + def test_preview_set_does_not_mutate_file(tmp_path: Path, monkeypatch): monkeypatch.chdir(tmp_path) env_file = tmp_path / ".env"