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

1# SPDX-License-Identifier: MIT 

2# Copyright © 2025 Bijan Mousavi 

3 

4"""Provides shared utilities for the `bijux plugins` command group. 

5 

6This module centralizes common logic for managing CLI plugins. It offers 

7helper functions for tasks such as: 

8 

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""" 

16 

17from __future__ import annotations 

18 

19from pathlib import Path 

20import re 

21 

22PLUGIN_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$") 

23 

24 

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. 

27 

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. 

31 

32 Args: 

33 dirpath (str): The path to the directory being scanned. 

34 names (list[str]): A list of names of items within `dirpath`. 

35 

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 

54 

55 

56def parse_required_cli_version(plugin_py: Path) -> str | None: 

57 """Parses `requires_cli_version` from a plugin file without executing it. 

58 

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. 

63 

64 Args: 

65 plugin_py (Path): The path to the `plugin.py` file to parse. 

66 

67 Returns: 

68 str | None: The version specifier string if found, otherwise None. 

69 """ 

70 import ast 

71 

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 

105 

106 return None 

107 except ( 

108 FileNotFoundError, 

109 PermissionError, 

110 SyntaxError, 

111 UnicodeDecodeError, 

112 OSError, 

113 ): 

114 return None 

115 

116 

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. 

126 

127 This serves as a security precaution to prevent plugin operations on 

128 unexpected filesystem locations. 

129 

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. 

137 

138 Returns: 

139 None: 

140 

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 

147 

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 )