Coverage for / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / plugins / commands / validation.py: 99%
43 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright © 2025 Bijan Mousavi
4"""Provides shared utilities for the `bijux plugins` command group.
6This module centralizes common logic for managing CLI plugins. It offers
7helper functions for tasks such as:
9* Safely traversing plugin directories for copy operations.
10* Parsing metadata from `plugin.py` files without code execution by
11 using the Abstract Syntax Tree (AST).
12* Performing security checks, like refusing to operate on directories
13 that are symbolic links.
14* Validating plugin names against a standard pattern.
15"""
17from __future__ import annotations
19from pathlib import Path
21from bijux_cli.core.enums import ErrorType, LogLevel, OutputFormat
22from bijux_cli.core.exit_policy import ExitIntentError
23from bijux_cli.core.precedence import resolve_exit_intent
26def ignore_hidden_and_broken_symlinks(dirpath: str, names: list[str]) -> list[str]:
27 """Creates a list of files and directories to ignore during a copy operation.
29 This function is designed to be used as the `ignore` callable for
30 `shutil.copytree`. It skips hidden files (starting with "."), the
31 `plugin.py` file, and any broken symbolic links.
33 Args:
34 dirpath (str): The path to the directory being scanned.
35 names (list[str]): A list of names of items within `dirpath`.
37 Returns:
38 list[str]: A list of item names to be ignored by `shutil.copytree`.
39 """
40 skip = []
41 base = Path(dirpath)
42 for name in names:
43 if name == "plugin.py":
44 continue
45 if name.startswith("."):
46 skip.append(name)
47 continue
48 entry = base / name
49 if entry.is_symlink():
50 try:
51 entry.resolve(strict=True)
52 except (FileNotFoundError, OSError):
53 skip.append(name)
54 return skip
57def parse_required_cli_version(plugin_py: Path) -> str | None:
58 """Parses `requires_cli_version` from a plugin file without executing it.
60 This function safely inspects a Python file using the Abstract Syntax Tree
61 (AST) to find the value of a top-level or class-level variable named
62 `requires_cli_version`. This avoids the security risks of importing or
63 executing untrusted code.
65 Args:
66 plugin_py (Path): The path to the `plugin.py` file to parse.
68 Returns:
69 str | None: The version specifier string if found, otherwise None.
70 """
71 import ast
73 try:
74 with plugin_py.open("r") as f:
75 tree = ast.parse(f.read(), filename=str(plugin_py))
76 for node in ast.walk(tree):
77 if isinstance(node, ast.Assign):
78 for target in node.targets:
79 if (
80 (
81 (
82 isinstance(target, ast.Attribute)
83 and target.attr == "requires_cli_version"
84 )
85 or (
86 isinstance(target, ast.Name)
87 and target.id == "requires_cli_version"
88 )
89 )
90 and isinstance(node.value, ast.Constant)
91 and isinstance(node.value.value, str)
92 ):
93 return node.value.value
94 if isinstance(node, ast.ClassDef) and node.name == "Plugin":
95 for stmt in node.body:
96 if (
97 isinstance(stmt, ast.Assign)
98 and any(
99 isinstance(t, ast.Name) and t.id == "requires_cli_version"
100 for t in stmt.targets
101 )
102 and isinstance(stmt.value, ast.Constant)
103 and isinstance(stmt.value.value, str)
104 ):
105 return stmt.value.value
107 return None
108 except (
109 FileNotFoundError,
110 PermissionError,
111 SyntaxError,
112 UnicodeDecodeError,
113 OSError,
114 ):
115 return None
118def refuse_on_symlink(
119 directory: Path,
120 command: str,
121 fmt: OutputFormat,
122 quiet: bool,
123 log_level: LogLevel,
124) -> None:
125 """Emits an error and exits if the given directory is a symbolic link.
127 This serves as a security precaution to prevent plugin operations on
128 unexpected filesystem locations.
130 Args:
131 directory (Path): The path to check.
132 command (str): The invoking command name for the error payload.
133 fmt (OutputFormat): The requested output format for the error payload.
134 quiet (bool): If True, suppresses output before exiting.
135 log_level (LogLevel): Logging level for diagnostics.
137 Returns:
138 None:
140 Raises:
141 SystemExit: Always exits the process with code 1 if `directory` is
142 a symbolic link.
143 """
144 if directory.is_symlink():
145 verb = command.split()[-1]
146 intent = resolve_exit_intent(
147 message=f"Refusing to {verb}: plugins dir {directory.name!r} is a symlink.",
148 code=1,
149 failure="symlink_dir",
150 command=command,
151 fmt=fmt,
152 quiet=quiet,
153 include_runtime=False,
154 error_type=ErrorType.USER_INPUT,
155 log_level=log_level,
156 )
157 raise ExitIntentError(intent)