bash-classify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bash_classify/__init__.py +14 -0
- bash_classify/__main__.py +3 -0
- bash_classify/classifier.py +244 -0
- bash_classify/cli.py +110 -0
- bash_classify/commands/apt.yaml +23 -0
- bash_classify/commands/awk.yaml +5 -0
- bash_classify/commands/basename.yaml +5 -0
- bash_classify/commands/bash.yaml +18 -0
- bash_classify/commands/brew.yaml +25 -0
- bash_classify/commands/builtin.yaml +6 -0
- bash_classify/commands/cargo.yaml +29 -0
- bash_classify/commands/cat.yaml +5 -0
- bash_classify/commands/chgrp.yaml +8 -0
- bash_classify/commands/chmod.yaml +8 -0
- bash_classify/commands/chown.yaml +8 -0
- bash_classify/commands/column.yaml +5 -0
- bash_classify/commands/comm.yaml +5 -0
- bash_classify/commands/command.yaml +10 -0
- bash_classify/commands/cp.yaml +5 -0
- bash_classify/commands/curl.yaml +28 -0
- bash_classify/commands/cut.yaml +5 -0
- bash_classify/commands/date.yaml +8 -0
- bash_classify/commands/df.yaml +5 -0
- bash_classify/commands/diff.yaml +5 -0
- bash_classify/commands/dig.yaml +5 -0
- bash_classify/commands/dirname.yaml +5 -0
- bash_classify/commands/docker.yaml +152 -0
- bash_classify/commands/du.yaml +5 -0
- bash_classify/commands/echo.yaml +5 -0
- bash_classify/commands/env.yaml +14 -0
- bash_classify/commands/expand.yaml +5 -0
- bash_classify/commands/false.yaml +5 -0
- bash_classify/commands/file.yaml +5 -0
- bash_classify/commands/find.yaml +27 -0
- bash_classify/commands/fmt.yaml +5 -0
- bash_classify/commands/fold.yaml +5 -0
- bash_classify/commands/free.yaml +5 -0
- bash_classify/commands/gawk.yaml +5 -0
- bash_classify/commands/git.yaml +441 -0
- bash_classify/commands/go.yaml +38 -0
- bash_classify/commands/grep.yaml +5 -0
- bash_classify/commands/groups.yaml +5 -0
- bash_classify/commands/gunzip.yaml +5 -0
- bash_classify/commands/gzip.yaml +5 -0
- bash_classify/commands/head.yaml +5 -0
- bash_classify/commands/helm.yaml +127 -0
- bash_classify/commands/hostname.yaml +5 -0
- bash_classify/commands/id.yaml +5 -0
- bash_classify/commands/install.yaml +5 -0
- bash_classify/commands/join.yaml +5 -0
- bash_classify/commands/journalctl.yaml +12 -0
- bash_classify/commands/jq.yaml +5 -0
- bash_classify/commands/kill.yaml +5 -0
- bash_classify/commands/killall.yaml +5 -0
- bash_classify/commands/kubectl.yaml +190 -0
- bash_classify/commands/less.yaml +5 -0
- bash_classify/commands/ln.yaml +5 -0
- bash_classify/commands/ls.yaml +5 -0
- bash_classify/commands/make.yaml +19 -0
- bash_classify/commands/md5sum.yaml +5 -0
- bash_classify/commands/mkdir.yaml +5 -0
- bash_classify/commands/more.yaml +5 -0
- bash_classify/commands/mv.yaml +5 -0
- bash_classify/commands/netstat.yaml +5 -0
- bash_classify/commands/nice.yaml +9 -0
- bash_classify/commands/nl.yaml +5 -0
- bash_classify/commands/node.yaml +5 -0
- bash_classify/commands/nohup.yaml +6 -0
- bash_classify/commands/npm.yaml +65 -0
- bash_classify/commands/nslookup.yaml +5 -0
- bash_classify/commands/paste.yaml +5 -0
- bash_classify/commands/ping.yaml +5 -0
- bash_classify/commands/pip.yaml +29 -0
- bash_classify/commands/pip3.yaml +29 -0
- bash_classify/commands/pkill.yaml +5 -0
- bash_classify/commands/poetry.yaml +74 -0
- bash_classify/commands/printenv.yaml +5 -0
- bash_classify/commands/printf.yaml +5 -0
- bash_classify/commands/ps.yaml +5 -0
- bash_classify/commands/pwd.yaml +5 -0
- bash_classify/commands/python.yaml +5 -0
- bash_classify/commands/python3.yaml +5 -0
- bash_classify/commands/read.yaml +5 -0
- bash_classify/commands/readlink.yaml +5 -0
- bash_classify/commands/realpath.yaml +5 -0
- bash_classify/commands/rev.yaml +5 -0
- bash_classify/commands/rm.yaml +5 -0
- bash_classify/commands/rsync.yaml +10 -0
- bash_classify/commands/scp.yaml +5 -0
- bash_classify/commands/sed.yaml +12 -0
- bash_classify/commands/seq.yaml +5 -0
- bash_classify/commands/sh.yaml +12 -0
- bash_classify/commands/sha1sum.yaml +5 -0
- bash_classify/commands/sha256sum.yaml +5 -0
- bash_classify/commands/sort.yaml +8 -0
- bash_classify/commands/ss.yaml +5 -0
- bash_classify/commands/ssh.yaml +39 -0
- bash_classify/commands/stat.yaml +5 -0
- bash_classify/commands/strace.yaml +18 -0
- bash_classify/commands/sudo.yaml +22 -0
- bash_classify/commands/systemctl.yaml +56 -0
- bash_classify/commands/tac.yaml +5 -0
- bash_classify/commands/tail.yaml +5 -0
- bash_classify/commands/tar.yaml +22 -0
- bash_classify/commands/tee.yaml +5 -0
- bash_classify/commands/terraform.yaml +68 -0
- bash_classify/commands/test.yaml +5 -0
- bash_classify/commands/time.yaml +6 -0
- bash_classify/commands/timeout.yaml +15 -0
- bash_classify/commands/top.yaml +5 -0
- bash_classify/commands/touch.yaml +5 -0
- bash_classify/commands/tr.yaml +5 -0
- bash_classify/commands/traceroute.yaml +5 -0
- bash_classify/commands/true.yaml +5 -0
- bash_classify/commands/type.yaml +5 -0
- bash_classify/commands/uname.yaml +5 -0
- bash_classify/commands/unexpand.yaml +5 -0
- bash_classify/commands/uniq.yaml +5 -0
- bash_classify/commands/unzip.yaml +7 -0
- bash_classify/commands/uptime.yaml +5 -0
- bash_classify/commands/uv.yaml +117 -0
- bash_classify/commands/wc.yaml +5 -0
- bash_classify/commands/wget.yaml +5 -0
- bash_classify/commands/whereis.yaml +5 -0
- bash_classify/commands/which.yaml +5 -0
- bash_classify/commands/whoami.yaml +5 -0
- bash_classify/commands/xargs.yaml +25 -0
- bash_classify/commands/xmllint.yaml +5 -0
- bash_classify/commands/yes.yaml +5 -0
- bash_classify/commands/yq.yaml +8 -0
- bash_classify/commands/zip.yaml +5 -0
- bash_classify/commands/zsh.yaml +12 -0
- bash_classify/database.py +257 -0
- bash_classify/matcher.py +702 -0
- bash_classify/models.py +148 -0
- bash_classify/parser.py +525 -0
- bash_classify/py.typed +0 -0
- bash_classify-0.1.0.dist-info/METADATA +147 -0
- bash_classify-0.1.0.dist-info/RECORD +142 -0
- bash_classify-0.1.0.dist-info/WHEEL +4 -0
- bash_classify-0.1.0.dist-info/entry_points.txt +2 -0
- bash_classify-0.1.0.dist-info/licenses/LICENSE +21 -0
bash_classify/models.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Data models for bash-classify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Classification(enum.Enum):
|
|
10
|
+
"""Classification levels for commands, ordered by severity."""
|
|
11
|
+
|
|
12
|
+
READONLY = "READONLY"
|
|
13
|
+
WRITE = "WRITE"
|
|
14
|
+
DANGEROUS = "DANGEROUS"
|
|
15
|
+
UNKNOWN = "UNKNOWN"
|
|
16
|
+
|
|
17
|
+
def severity(self) -> int:
|
|
18
|
+
"""Return the severity ordering for this classification.
|
|
19
|
+
|
|
20
|
+
DANGEROUS > UNKNOWN > WRITE > READONLY
|
|
21
|
+
"""
|
|
22
|
+
return _SEVERITY_ORDER[self]
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def max_severity(cls, *classifications: Classification) -> Classification:
|
|
26
|
+
"""Return the classification with the highest severity."""
|
|
27
|
+
if not classifications:
|
|
28
|
+
return cls.READONLY
|
|
29
|
+
return max(classifications, key=lambda c: c.severity())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_SEVERITY_ORDER: dict[Classification, int] = {
|
|
33
|
+
Classification.READONLY: 0,
|
|
34
|
+
Classification.WRITE: 1,
|
|
35
|
+
Classification.UNKNOWN: 2,
|
|
36
|
+
Classification.DANGEROUS: 3,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DelegationMode(enum.Enum):
|
|
41
|
+
"""How a command delegates execution to an inner command."""
|
|
42
|
+
|
|
43
|
+
REST_ARE_ARGV = "rest_are_argv"
|
|
44
|
+
AFTER_SEPARATOR = "after_separator"
|
|
45
|
+
TERMINATED_ARGV = "terminated_argv"
|
|
46
|
+
FLAG_VALUE_IS_EXPRESSION = "flag_value_is_expression"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Redirect:
|
|
51
|
+
"""A shell redirect extracted from parsing."""
|
|
52
|
+
|
|
53
|
+
operator: str
|
|
54
|
+
target: str
|
|
55
|
+
affects_classification: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CommandInvocation:
|
|
60
|
+
"""A single command invocation extracted from the parsed bash AST."""
|
|
61
|
+
|
|
62
|
+
argv: list[str]
|
|
63
|
+
redirects: list[Redirect]
|
|
64
|
+
position_in_pipeline: int
|
|
65
|
+
pipeline_length: int
|
|
66
|
+
context: str # "toplevel" | "subshell" | "command_substitution" | "process_substitution"
|
|
67
|
+
operator_before: str | None
|
|
68
|
+
is_background: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class DelegationConfig:
|
|
73
|
+
"""Configuration for how a command delegates to an inner command."""
|
|
74
|
+
|
|
75
|
+
mode: DelegationMode
|
|
76
|
+
separator: str | None = None
|
|
77
|
+
terminator: str | None = None
|
|
78
|
+
flag: str | None = None
|
|
79
|
+
strip_assignments: bool = False
|
|
80
|
+
min_classification: Classification | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class OptionDef:
|
|
85
|
+
"""Definition of a command option from the database."""
|
|
86
|
+
|
|
87
|
+
takes_value: bool = False
|
|
88
|
+
aliases: list[str] = field(default_factory=list)
|
|
89
|
+
overrides: Classification | None = None
|
|
90
|
+
captures_directory: bool = False
|
|
91
|
+
delegates_to: DelegationConfig | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class CommandDef:
|
|
96
|
+
"""Definition of a command (or subcommand) from the database."""
|
|
97
|
+
|
|
98
|
+
command: str
|
|
99
|
+
classification: Classification | None = None
|
|
100
|
+
global_options: dict[str, OptionDef] = field(default_factory=dict)
|
|
101
|
+
subcommands: dict[str, CommandDef] = field(default_factory=dict)
|
|
102
|
+
options: dict[str, OptionDef] = field(default_factory=dict)
|
|
103
|
+
strict: bool = True
|
|
104
|
+
delegates_to: DelegationConfig | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class InnerCommandResult:
|
|
109
|
+
"""Result of classifying a delegated inner command."""
|
|
110
|
+
|
|
111
|
+
delegation_mode: str
|
|
112
|
+
delegation_source: str
|
|
113
|
+
command: list[str]
|
|
114
|
+
argv: list[str]
|
|
115
|
+
classification: Classification
|
|
116
|
+
matched_rule: str | None
|
|
117
|
+
inner_commands: list[InnerCommandResult]
|
|
118
|
+
ignored_options: list[str] | None = None
|
|
119
|
+
remaining_options: list[str] | None = None
|
|
120
|
+
overriding_option: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class CommandResult:
|
|
125
|
+
"""Result of classifying a single top-level command."""
|
|
126
|
+
|
|
127
|
+
command: list[str]
|
|
128
|
+
argv: list[str]
|
|
129
|
+
classification: Classification
|
|
130
|
+
matched_rule: str | None
|
|
131
|
+
inner_commands: list[InnerCommandResult]
|
|
132
|
+
ignored_options: list[str] | None = None
|
|
133
|
+
remaining_options: list[str] | None = None
|
|
134
|
+
classification_reason: str | None = None
|
|
135
|
+
overriding_option: str | None = None
|
|
136
|
+
directories: list[str] | None = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class ExpressionResult:
|
|
141
|
+
"""Result of classifying a full bash expression."""
|
|
142
|
+
|
|
143
|
+
expression: str
|
|
144
|
+
classification: Classification
|
|
145
|
+
directories: list[str]
|
|
146
|
+
commands: list[CommandResult]
|
|
147
|
+
redirects: list[Redirect]
|
|
148
|
+
parse_warnings: list[str]
|
bash_classify/parser.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Bash expression parser using tree-sitter-bash."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tree_sitter
|
|
6
|
+
import tree_sitter_bash
|
|
7
|
+
|
|
8
|
+
from .models import CommandInvocation, Redirect
|
|
9
|
+
|
|
10
|
+
_parser = tree_sitter.Parser()
|
|
11
|
+
_parser.language = tree_sitter.Language(tree_sitter_bash.language())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_expression(expression: str) -> tuple[list[CommandInvocation], list[str]]:
|
|
15
|
+
"""Parse a bash expression into a list of CommandInvocation objects.
|
|
16
|
+
|
|
17
|
+
Uses tree-sitter-bash to parse the expression into a CST,
|
|
18
|
+
then walks the tree to extract command invocations.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A tuple of (invocations, parse_warnings).
|
|
22
|
+
"""
|
|
23
|
+
tree = _parser.parse(expression.encode())
|
|
24
|
+
warnings: list[str] = []
|
|
25
|
+
if tree.root_node.has_error:
|
|
26
|
+
warnings.append(f"tree-sitter reported a syntax error in expression: {expression!r}")
|
|
27
|
+
invocations = _walk_node(tree.root_node, context="toplevel", operator_before=None)
|
|
28
|
+
return invocations, warnings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _walk_node(
|
|
32
|
+
node: tree_sitter.Node,
|
|
33
|
+
*,
|
|
34
|
+
context: str,
|
|
35
|
+
operator_before: str | None,
|
|
36
|
+
pipeline_position: int = 0,
|
|
37
|
+
pipeline_length: int = 1,
|
|
38
|
+
) -> list[CommandInvocation]:
|
|
39
|
+
"""Recursively walk a tree-sitter node and extract command invocations."""
|
|
40
|
+
results: list[CommandInvocation] = []
|
|
41
|
+
|
|
42
|
+
if node.type == "program":
|
|
43
|
+
results.extend(_walk_children_as_list(node, context=context))
|
|
44
|
+
|
|
45
|
+
elif node.type == "list":
|
|
46
|
+
results.extend(_walk_list_node(node, context=context, operator_before=operator_before))
|
|
47
|
+
|
|
48
|
+
elif node.type == "pipeline":
|
|
49
|
+
results.extend(_walk_pipeline_node(node, context=context, operator_before=operator_before))
|
|
50
|
+
|
|
51
|
+
elif node.type == "command":
|
|
52
|
+
inv = _extract_command(
|
|
53
|
+
node,
|
|
54
|
+
context=context,
|
|
55
|
+
operator_before=operator_before,
|
|
56
|
+
pipeline_position=pipeline_position,
|
|
57
|
+
pipeline_length=pipeline_length,
|
|
58
|
+
)
|
|
59
|
+
if inv is not None:
|
|
60
|
+
results.append(inv)
|
|
61
|
+
# Also extract nested commands from command arguments (command_substitution, process_substitution)
|
|
62
|
+
results.extend(_extract_nested_from_command_args(node))
|
|
63
|
+
|
|
64
|
+
elif node.type == "redirected_statement":
|
|
65
|
+
results.extend(
|
|
66
|
+
_walk_redirected_statement(
|
|
67
|
+
node,
|
|
68
|
+
context=context,
|
|
69
|
+
operator_before=operator_before,
|
|
70
|
+
pipeline_position=pipeline_position,
|
|
71
|
+
pipeline_length=pipeline_length,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
elif node.type == "subshell":
|
|
76
|
+
results.extend(_walk_subshell(node, context="subshell", operator_before=operator_before))
|
|
77
|
+
|
|
78
|
+
elif node.type == "command_substitution":
|
|
79
|
+
results.extend(_walk_compound_inner(node, context="command_substitution"))
|
|
80
|
+
|
|
81
|
+
elif node.type == "process_substitution":
|
|
82
|
+
results.extend(_walk_compound_inner(node, context="process_substitution"))
|
|
83
|
+
|
|
84
|
+
elif node.type == "test_command":
|
|
85
|
+
inv = _extract_test_command(
|
|
86
|
+
node,
|
|
87
|
+
context=context,
|
|
88
|
+
operator_before=operator_before,
|
|
89
|
+
pipeline_position=pipeline_position,
|
|
90
|
+
pipeline_length=pipeline_length,
|
|
91
|
+
)
|
|
92
|
+
if inv is not None:
|
|
93
|
+
results.append(inv)
|
|
94
|
+
|
|
95
|
+
elif node.type == "negated_command":
|
|
96
|
+
# Handle `! cmd` — extract the inner command
|
|
97
|
+
for child in node.children:
|
|
98
|
+
if child.type != "!":
|
|
99
|
+
results.extend(
|
|
100
|
+
_walk_node(
|
|
101
|
+
child,
|
|
102
|
+
context=context,
|
|
103
|
+
operator_before=operator_before,
|
|
104
|
+
pipeline_position=pipeline_position,
|
|
105
|
+
pipeline_length=pipeline_length,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
else:
|
|
110
|
+
# For any other node type, recurse into children
|
|
111
|
+
for child in node.children:
|
|
112
|
+
results.extend(
|
|
113
|
+
_walk_node(
|
|
114
|
+
child,
|
|
115
|
+
context=context,
|
|
116
|
+
operator_before=operator_before,
|
|
117
|
+
pipeline_position=pipeline_position,
|
|
118
|
+
pipeline_length=pipeline_length,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return results
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _walk_children_as_list(
|
|
126
|
+
node: tree_sitter.Node,
|
|
127
|
+
*,
|
|
128
|
+
context: str,
|
|
129
|
+
) -> list[CommandInvocation]:
|
|
130
|
+
"""Walk children of a program or compound node as a sequence of statements."""
|
|
131
|
+
results: list[CommandInvocation] = []
|
|
132
|
+
operator_before: str | None = None
|
|
133
|
+
|
|
134
|
+
for child in node.children:
|
|
135
|
+
if child.type in ("\n", ";", "&", ";;"):
|
|
136
|
+
if child.type == ";":
|
|
137
|
+
operator_before = ";"
|
|
138
|
+
elif child.type == "&":
|
|
139
|
+
# Background operator — mark previous command; next command gets no operator_before from this
|
|
140
|
+
_mark_last_background(results)
|
|
141
|
+
operator_before = None
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
child_results = _walk_node(child, context=context, operator_before=operator_before)
|
|
145
|
+
results.extend(child_results)
|
|
146
|
+
operator_before = None
|
|
147
|
+
|
|
148
|
+
return results
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _walk_list_node(
|
|
152
|
+
node: tree_sitter.Node,
|
|
153
|
+
*,
|
|
154
|
+
context: str,
|
|
155
|
+
operator_before: str | None,
|
|
156
|
+
) -> list[CommandInvocation]:
|
|
157
|
+
"""Walk a 'list' node which connects commands with &&, ||, ;, or &."""
|
|
158
|
+
results: list[CommandInvocation] = []
|
|
159
|
+
current_operator = operator_before
|
|
160
|
+
|
|
161
|
+
for child in node.children:
|
|
162
|
+
if child.type in ("&&", "||", ";"):
|
|
163
|
+
current_operator = child.type
|
|
164
|
+
continue
|
|
165
|
+
if child.type == "&":
|
|
166
|
+
_mark_last_background(results)
|
|
167
|
+
current_operator = None
|
|
168
|
+
continue
|
|
169
|
+
if child.type in ("\n",):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
child_results = _walk_node(child, context=context, operator_before=current_operator)
|
|
173
|
+
results.extend(child_results)
|
|
174
|
+
current_operator = None
|
|
175
|
+
|
|
176
|
+
return results
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _walk_pipeline_node(
|
|
180
|
+
node: tree_sitter.Node,
|
|
181
|
+
*,
|
|
182
|
+
context: str,
|
|
183
|
+
operator_before: str | None,
|
|
184
|
+
) -> list[CommandInvocation]:
|
|
185
|
+
"""Walk a pipeline node, extracting each command with its pipeline position."""
|
|
186
|
+
# Collect command nodes in the pipeline (skip | operators)
|
|
187
|
+
command_nodes = [child for child in node.children if child.type not in ("|", "|&")]
|
|
188
|
+
pipe_len = len(command_nodes)
|
|
189
|
+
|
|
190
|
+
results: list[CommandInvocation] = []
|
|
191
|
+
for i, cmd_node in enumerate(command_nodes):
|
|
192
|
+
op = operator_before if i == 0 else None
|
|
193
|
+
child_results = _walk_node(
|
|
194
|
+
cmd_node, context=context, operator_before=op, pipeline_position=i, pipeline_length=pipe_len
|
|
195
|
+
)
|
|
196
|
+
results.extend(child_results)
|
|
197
|
+
|
|
198
|
+
return results
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _walk_redirected_statement(
|
|
202
|
+
node: tree_sitter.Node,
|
|
203
|
+
*,
|
|
204
|
+
context: str,
|
|
205
|
+
operator_before: str | None,
|
|
206
|
+
pipeline_position: int,
|
|
207
|
+
pipeline_length: int,
|
|
208
|
+
) -> list[CommandInvocation]:
|
|
209
|
+
"""Walk a redirected_statement node, extracting the command and its redirects."""
|
|
210
|
+
results: list[CommandInvocation] = []
|
|
211
|
+
redirects = _extract_redirects_from_node(node)
|
|
212
|
+
|
|
213
|
+
# The first child is typically the actual command/pipeline/etc
|
|
214
|
+
body_node = None
|
|
215
|
+
for child in node.children:
|
|
216
|
+
if child.type not in ("file_redirect", "heredoc_redirect", "herestring_redirect"):
|
|
217
|
+
body_node = child
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
if body_node is None:
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
# Get the commands from the body
|
|
224
|
+
inner = _walk_node(
|
|
225
|
+
body_node,
|
|
226
|
+
context=context,
|
|
227
|
+
operator_before=operator_before,
|
|
228
|
+
pipeline_position=pipeline_position,
|
|
229
|
+
pipeline_length=pipeline_length,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Attach redirects only to the first command from the body to avoid duplication
|
|
233
|
+
# (e.g., redirected compound statements like `for ... done > file` should only
|
|
234
|
+
# attach the redirect once, not to every command inside the loop body)
|
|
235
|
+
redirects_attached = False
|
|
236
|
+
for inv in inner:
|
|
237
|
+
if not redirects_attached and inv.position_in_pipeline == pipeline_position:
|
|
238
|
+
if not inv.redirects:
|
|
239
|
+
inv.redirects = redirects
|
|
240
|
+
else:
|
|
241
|
+
inv.redirects.extend(redirects)
|
|
242
|
+
redirects_attached = True
|
|
243
|
+
|
|
244
|
+
results.extend(inner)
|
|
245
|
+
|
|
246
|
+
# Also extract nested commands from redirect targets (shouldn't normally happen, but be safe)
|
|
247
|
+
return results
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _walk_subshell(
|
|
251
|
+
node: tree_sitter.Node,
|
|
252
|
+
*,
|
|
253
|
+
context: str,
|
|
254
|
+
operator_before: str | None,
|
|
255
|
+
) -> list[CommandInvocation]:
|
|
256
|
+
"""Walk a subshell node — inner commands get context='subshell'."""
|
|
257
|
+
results: list[CommandInvocation] = []
|
|
258
|
+
for child in node.children:
|
|
259
|
+
if child.type in ("(", ")"):
|
|
260
|
+
continue
|
|
261
|
+
results.extend(_walk_node(child, context=context, operator_before=operator_before))
|
|
262
|
+
operator_before = None
|
|
263
|
+
return results
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _walk_compound_inner(
|
|
267
|
+
node: tree_sitter.Node,
|
|
268
|
+
*,
|
|
269
|
+
context: str,
|
|
270
|
+
) -> list[CommandInvocation]:
|
|
271
|
+
"""Walk inner content of command_substitution or process_substitution."""
|
|
272
|
+
results: list[CommandInvocation] = []
|
|
273
|
+
for child in node.children:
|
|
274
|
+
if child.type in ("$(", ")", "<(", ">(", "`"):
|
|
275
|
+
continue
|
|
276
|
+
results.extend(_walk_node(child, context=context, operator_before=None))
|
|
277
|
+
return results
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _extract_command(
|
|
281
|
+
node: tree_sitter.Node,
|
|
282
|
+
*,
|
|
283
|
+
context: str,
|
|
284
|
+
operator_before: str | None,
|
|
285
|
+
pipeline_position: int,
|
|
286
|
+
pipeline_length: int,
|
|
287
|
+
) -> CommandInvocation | None:
|
|
288
|
+
"""Extract a CommandInvocation from a 'command' node."""
|
|
289
|
+
argv: list[str] = []
|
|
290
|
+
has_command_name = False
|
|
291
|
+
|
|
292
|
+
for child in node.children:
|
|
293
|
+
if child.type == "variable_assignment":
|
|
294
|
+
# Strip variable assignments before the command name
|
|
295
|
+
if not has_command_name:
|
|
296
|
+
continue
|
|
297
|
+
# After command name, treat as argument
|
|
298
|
+
argv.append(_node_text(child))
|
|
299
|
+
elif child.type == "command_name":
|
|
300
|
+
has_command_name = True
|
|
301
|
+
argv.append(_node_text(child))
|
|
302
|
+
elif child.type in ("file_redirect", "heredoc_redirect", "herestring_redirect"):
|
|
303
|
+
# Handled separately
|
|
304
|
+
continue
|
|
305
|
+
elif child.type == "comment":
|
|
306
|
+
continue
|
|
307
|
+
else:
|
|
308
|
+
# Arguments: word, string, raw_string, concatenation, etc.
|
|
309
|
+
argv.append(_node_text(child))
|
|
310
|
+
|
|
311
|
+
if not argv:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
redirects = _extract_redirects_from_node(node)
|
|
315
|
+
|
|
316
|
+
return CommandInvocation(
|
|
317
|
+
argv=argv,
|
|
318
|
+
redirects=redirects,
|
|
319
|
+
position_in_pipeline=pipeline_position,
|
|
320
|
+
pipeline_length=pipeline_length,
|
|
321
|
+
context=context,
|
|
322
|
+
operator_before=operator_before,
|
|
323
|
+
is_background=False,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _extract_test_command(
|
|
328
|
+
node: tree_sitter.Node,
|
|
329
|
+
*,
|
|
330
|
+
context: str,
|
|
331
|
+
operator_before: str | None,
|
|
332
|
+
pipeline_position: int,
|
|
333
|
+
pipeline_length: int,
|
|
334
|
+
) -> CommandInvocation | None:
|
|
335
|
+
"""Extract a CommandInvocation from a 'test_command' node ([ ... ] or [[ ... ]])."""
|
|
336
|
+
# The first child is '[' or '[[', use it as the command name
|
|
337
|
+
argv: list[str] = []
|
|
338
|
+
for child in node.children:
|
|
339
|
+
if child.type in ("[", "[["):
|
|
340
|
+
argv.append(child.text.decode())
|
|
341
|
+
elif child.type in ("]", "]]"):
|
|
342
|
+
# Closing bracket is not part of argv
|
|
343
|
+
continue
|
|
344
|
+
elif child.is_named:
|
|
345
|
+
argv.append(_node_text(child))
|
|
346
|
+
|
|
347
|
+
if not argv:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
return CommandInvocation(
|
|
351
|
+
argv=argv,
|
|
352
|
+
redirects=[],
|
|
353
|
+
position_in_pipeline=pipeline_position,
|
|
354
|
+
pipeline_length=pipeline_length,
|
|
355
|
+
context=context,
|
|
356
|
+
operator_before=operator_before,
|
|
357
|
+
is_background=False,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _extract_nested_from_command_args(node: tree_sitter.Node) -> list[CommandInvocation]:
|
|
362
|
+
"""Extract commands from nested command_substitution/process_substitution within command arguments."""
|
|
363
|
+
results: list[CommandInvocation] = []
|
|
364
|
+
for child in node.children:
|
|
365
|
+
if child.type == "command_name":
|
|
366
|
+
# Recurse into command_name children for command substitutions
|
|
367
|
+
for sub in child.children:
|
|
368
|
+
results.extend(_find_nested_substitutions(sub))
|
|
369
|
+
elif child.type in ("file_redirect", "heredoc_redirect", "herestring_redirect", "variable_assignment"):
|
|
370
|
+
continue
|
|
371
|
+
else:
|
|
372
|
+
results.extend(_find_nested_substitutions(child))
|
|
373
|
+
return results
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _find_nested_substitutions(node: tree_sitter.Node) -> list[CommandInvocation]:
|
|
377
|
+
"""Recursively find command_substitution and process_substitution nodes in arguments."""
|
|
378
|
+
results: list[CommandInvocation] = []
|
|
379
|
+
|
|
380
|
+
if node.type == "command_substitution":
|
|
381
|
+
results.extend(_walk_compound_inner(node, context="command_substitution"))
|
|
382
|
+
elif node.type == "process_substitution":
|
|
383
|
+
results.extend(_walk_compound_inner(node, context="process_substitution"))
|
|
384
|
+
else:
|
|
385
|
+
for child in node.children:
|
|
386
|
+
results.extend(_find_nested_substitutions(child))
|
|
387
|
+
|
|
388
|
+
return results
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _extract_redirects_from_node(node: tree_sitter.Node) -> list[Redirect]:
|
|
392
|
+
"""Extract Redirect objects from a node's redirect children."""
|
|
393
|
+
redirects: list[Redirect] = []
|
|
394
|
+
for child in node.children:
|
|
395
|
+
if child.type == "file_redirect":
|
|
396
|
+
redirect = _parse_file_redirect(child)
|
|
397
|
+
if redirect is not None:
|
|
398
|
+
redirects.append(redirect)
|
|
399
|
+
elif child.type == "heredoc_redirect":
|
|
400
|
+
redirect = _parse_heredoc_redirect(child)
|
|
401
|
+
if redirect is not None:
|
|
402
|
+
redirects.append(redirect)
|
|
403
|
+
elif child.type == "herestring_redirect":
|
|
404
|
+
redirect = _parse_herestring_redirect(child)
|
|
405
|
+
if redirect is not None:
|
|
406
|
+
redirects.append(redirect)
|
|
407
|
+
return redirects
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _parse_file_redirect(node: tree_sitter.Node) -> Redirect | None:
|
|
411
|
+
"""Parse a file_redirect node into a Redirect."""
|
|
412
|
+
# file_redirect children: optional fd, operator, target
|
|
413
|
+
operator_parts: list[str] = []
|
|
414
|
+
target = ""
|
|
415
|
+
|
|
416
|
+
for child in node.children:
|
|
417
|
+
if child.type == "file_descriptor" or child.type in (">", ">>", "<", ">&", "&>", "&>>", "<&", "<<", "<<<"):
|
|
418
|
+
operator_parts.append(child.text.decode())
|
|
419
|
+
elif child.is_named:
|
|
420
|
+
target = _node_text(child)
|
|
421
|
+
else:
|
|
422
|
+
# Unnamed nodes that are operators
|
|
423
|
+
text = child.text.decode()
|
|
424
|
+
if text in (">", ">>", "<", ">&", "&>", "&>>", "<&", "<<", "<<<"):
|
|
425
|
+
operator_parts.append(text)
|
|
426
|
+
elif not target:
|
|
427
|
+
target = text
|
|
428
|
+
|
|
429
|
+
operator = "".join(operator_parts)
|
|
430
|
+
if not operator:
|
|
431
|
+
# Fallback: extract operator from the full text
|
|
432
|
+
full_text = node.text.decode()
|
|
433
|
+
if ">" in full_text or "<" in full_text:
|
|
434
|
+
operator = full_text.split()[0] if full_text.split() else full_text
|
|
435
|
+
|
|
436
|
+
affects = _redirect_affects_classification(operator, target)
|
|
437
|
+
|
|
438
|
+
return Redirect(operator=operator, target=target, affects_classification=affects)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _parse_heredoc_redirect(node: tree_sitter.Node) -> Redirect | None:
|
|
442
|
+
"""Parse a heredoc_redirect node into a Redirect."""
|
|
443
|
+
# Extract the heredoc delimiter
|
|
444
|
+
operator = "<<"
|
|
445
|
+
target = ""
|
|
446
|
+
|
|
447
|
+
for child in node.children:
|
|
448
|
+
if child.type in ("<<", "<<-"):
|
|
449
|
+
operator = child.text.decode()
|
|
450
|
+
elif child.type == "heredoc_start":
|
|
451
|
+
target = child.text.decode()
|
|
452
|
+
|
|
453
|
+
return Redirect(operator=operator, target=target, affects_classification=False)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _parse_herestring_redirect(node: tree_sitter.Node) -> Redirect | None:
|
|
457
|
+
"""Parse a herestring_redirect node (<<<) into a Redirect."""
|
|
458
|
+
target = ""
|
|
459
|
+
for child in node.children:
|
|
460
|
+
if child.type == "<<<":
|
|
461
|
+
continue
|
|
462
|
+
elif child.is_named:
|
|
463
|
+
target = _node_text(child)
|
|
464
|
+
else:
|
|
465
|
+
text = child.text.decode()
|
|
466
|
+
if text != "<<<" and not target:
|
|
467
|
+
target = text
|
|
468
|
+
|
|
469
|
+
return Redirect(operator="<<<", target=target, affects_classification=False)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _redirect_affects_classification(operator: str, target: str) -> bool:
|
|
473
|
+
"""Determine if a redirect affects classification (i.e., writes to a file)."""
|
|
474
|
+
if target == "/dev/null":
|
|
475
|
+
return False
|
|
476
|
+
# fd-to-fd redirects like 2>&1, 1>&2 do not affect classification
|
|
477
|
+
if ">&" in operator and target.isdigit():
|
|
478
|
+
return False
|
|
479
|
+
# Output redirects affect classification
|
|
480
|
+
if operator in (">", ">>", "&>", "&>>") or (operator.endswith(">") and "<" not in operator):
|
|
481
|
+
return True
|
|
482
|
+
# Numbered output redirects like 2>
|
|
483
|
+
return len(operator) >= 2 and operator[0].isdigit() and ">" in operator
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _node_text(node: tree_sitter.Node) -> str:
|
|
487
|
+
"""Extract the text content of a node, stripping outer quotes from string nodes.
|
|
488
|
+
|
|
489
|
+
- `string` nodes ("...") have their outer double quotes stripped.
|
|
490
|
+
- `raw_string` nodes ('...') have their outer single quotes stripped.
|
|
491
|
+
- `$'...'` (ANSI-C quoting) is also stripped.
|
|
492
|
+
- `concatenation` nodes join their children (each child processed).
|
|
493
|
+
- Regular `word` nodes are returned as-is.
|
|
494
|
+
"""
|
|
495
|
+
if node.type == "string":
|
|
496
|
+
# Double-quoted string: "content"
|
|
497
|
+
text = node.text.decode()
|
|
498
|
+
if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
|
|
499
|
+
return text[1:-1]
|
|
500
|
+
return text
|
|
501
|
+
elif node.type == "raw_string":
|
|
502
|
+
# Single-quoted string: 'content' or $'content'
|
|
503
|
+
text = node.text.decode()
|
|
504
|
+
if len(text) >= 2 and text[0] == "'" and text[-1] == "'":
|
|
505
|
+
return text[1:-1]
|
|
506
|
+
if len(text) >= 3 and text[:2] == "$'" and text[-1] == "'":
|
|
507
|
+
return text[2:-1]
|
|
508
|
+
return text
|
|
509
|
+
elif node.type == "ansi_c_string":
|
|
510
|
+
# ANSI-C quoted string: $'content'
|
|
511
|
+
text = node.text.decode()
|
|
512
|
+
if len(text) >= 3 and text[:2] == "$'" and text[-1] == "'":
|
|
513
|
+
return text[2:-1]
|
|
514
|
+
return text
|
|
515
|
+
elif node.type == "concatenation":
|
|
516
|
+
# Join children, each processed individually
|
|
517
|
+
return "".join(_node_text(child) for child in node.children)
|
|
518
|
+
else:
|
|
519
|
+
return node.text.decode()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _mark_last_background(results: list[CommandInvocation]) -> None:
|
|
523
|
+
"""Mark the last command in the results list as background."""
|
|
524
|
+
if results:
|
|
525
|
+
results[-1].is_background = True
|
bash_classify/py.typed
ADDED
|
File without changes
|