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

1# SPDX-License-Identifier: Apache-2.0 

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 

20 

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 

24 

25 

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. 

28 

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. 

32 

33 Args: 

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

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

36 

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 

55 

56 

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

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

59 

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. 

64 

65 Args: 

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

67 

68 Returns: 

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

70 """ 

71 import ast 

72 

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 

106 

107 return None 

108 except ( 

109 FileNotFoundError, 

110 PermissionError, 

111 SyntaxError, 

112 UnicodeDecodeError, 

113 OSError, 

114 ): 

115 return None 

116 

117 

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. 

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 (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. 

136 

137 Returns: 

138 None: 

139 

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)