Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/plugins/utils.py: 100%
42 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
1# SPDX-License-Identifier: MIT
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
20import re
22PLUGIN_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
25def ignore_hidden_and_broken_symlinks(dirpath: str, names: list[str]) -> list[str]:
26 """Creates a list of files and directories to ignore during a copy operation.
28 This function is designed to be used as the `ignore` callable for
29 `shutil.copytree`. It skips hidden files (starting with "."), the
30 `plugin.py` file, and any broken symbolic links.
32 Args:
33 dirpath (str): The path to the directory being scanned.
34 names (list[str]): A list of names of items within `dirpath`.
36 Returns:
37 list[str]: A list of item names to be ignored by `shutil.copytree`.
38 """
39 skip = []
40 base = Path(dirpath)
41 for name in names:
42 if name == "plugin.py":
43 continue
44 if name.startswith("."):
45 skip.append(name)
46 continue
47 entry = base / name
48 if entry.is_symlink():
49 try:
50 entry.resolve(strict=True)
51 except (FileNotFoundError, OSError):
52 skip.append(name)
53 return skip
56def parse_required_cli_version(plugin_py: Path) -> str | None:
57 """Parses `requires_cli_version` from a plugin file without executing it.
59 This function safely inspects a Python file using the Abstract Syntax Tree
60 (AST) to find the value of a top-level or class-level variable named
61 `requires_cli_version`. This avoids the security risks of importing or
62 executing untrusted code.
64 Args:
65 plugin_py (Path): The path to the `plugin.py` file to parse.
67 Returns:
68 str | None: The version specifier string if found, otherwise None.
69 """
70 import ast
72 try:
73 with plugin_py.open("r") as f:
74 tree = ast.parse(f.read(), filename=str(plugin_py))
75 for node in ast.walk(tree):
76 if isinstance(node, ast.Assign):
77 for target in node.targets:
78 if (
79 (
80 (
81 isinstance(target, ast.Attribute)
82 and target.attr == "requires_cli_version"
83 )
84 or (
85 isinstance(target, ast.Name)
86 and target.id == "requires_cli_version"
87 )
88 )
89 and isinstance(node.value, ast.Constant)
90 and isinstance(node.value.value, str)
91 ):
92 return node.value.value
93 if isinstance(node, ast.ClassDef) and node.name == "Plugin":
94 for stmt in node.body:
95 if (
96 isinstance(stmt, ast.Assign)
97 and any(
98 isinstance(t, ast.Name) and t.id == "requires_cli_version"
99 for t in stmt.targets
100 )
101 and isinstance(stmt.value, ast.Constant)
102 and isinstance(stmt.value.value, str)
103 ):
104 return stmt.value.value
106 return None
107 except (
108 FileNotFoundError,
109 PermissionError,
110 SyntaxError,
111 UnicodeDecodeError,
112 OSError,
113 ):
114 return None
117def refuse_on_symlink(
118 directory: Path,
119 command: str,
120 fmt: str,
121 quiet: bool,
122 verbose: bool,
123 debug: bool,
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 (str): The requested output format for the error payload.
134 quiet (bool): If True, suppresses output before exiting.
135 verbose (bool): If True, includes runtime info in the error payload.
136 debug (bool): If True, enables debug diagnostics.
138 Returns:
139 None:
141 Raises:
142 SystemExit: Always exits the process with code 1 if `directory` is
143 a symbolic link.
144 """
145 if directory.is_symlink():
146 from bijux_cli.commands.utilities import emit_error_and_exit
148 verb = command.split()[-1]
149 emit_error_and_exit(
150 f"Refusing to {verb}: plugins dir {directory.name!r} is a symlink.",
151 code=1,
152 failure="symlink_dir",
153 command=command,
154 fmt=fmt,
155 quiet=quiet,
156 include_runtime=verbose,
157 debug=debug,
158 )