pikuri-code 0.0.6 → 0.1.0

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.
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Pikuri
6
+ module Code
7
+ class Bash
8
+ # A deliberately narrow shell tokenizer: turns a command string into one
9
+ # word list per *sequencer segment*, or +nil+ when it can't prove the
10
+ # command is such a simple chain. It makes **no passivity judgement** —
11
+ # that policy lives in {PassiveCommandDetector}, which classifies these
12
+ # word lists. Lexing (here) is split from classification (the passive detector) so
13
+ # a second consumer could reuse the parse.
14
+ #
15
+ # {.tokenize} returns +Array<Array<String>>+ — segments (cut on
16
+ # {SEQUENCERS}) of de-quoted, de-redirected words, an empty list marking a
17
+ # pure-comment no-op — or +nil+ meaning "more than a simple chain" (a
18
+ # metacharacter, unsafe redirect, unbalanced quote, too many segments).
19
+ # Conservative by contract: when in doubt it returns +nil+ so the caller
20
+ # delegates to a human; it never silently drops a dangerous byte.
21
+ #
22
+ # +allow_glob:+ is the one knob: off (default) an unquoted glob/brace/
23
+ # tilde ({GLOB_CHARS}) rejects like any metacharacter; on, it is kept as
24
+ # a literal word byte and the *authorization* layer decides. This is
25
+ # sound because those chars only ever expand to *words*, never to an
26
+ # operator or a new command — so keeping them literal can't smuggle a
27
+ # +;+/+|+/+$()+ past the lexer. The detector flips it on only for the
28
+ # pure-allowlist glob fast path; see {PassiveCommandDetector}.
29
+ #
30
+ # == Why splitting on the sequencers is faithful
31
+ #
32
+ # {.split_segments} cuts only on a genuine control operator, never one
33
+ # hidden inside a string: it tracks quote state (single *and* double) and
34
+ # won't split inside a quoted span, and the backslash stays forbidden, so
35
+ # nothing escapes a +;+/+|+/newline past it. Every *other* metacharacter
36
+ # stays forbidden per segment, so an arbitrary-target redirect (+ps | grep
37
+ # x > out+), a background +&+ (+ls & pwd+) or +|&+ trip the metacharacter
38
+ # check ⇒ +nil+. The lone exception is a write-nothing redirect fragment
39
+ # ({SAFE_REDIRECT}: +2>&1+, +2>/dev/null+ …), consumed and dropped — fd
40
+ # duplication and +/dev/null+ discards add no capability.
41
+ #
42
+ # == Quote removal
43
+ #
44
+ # {.scan_segment} performs genuine shell quote-removal: the marks are
45
+ # dropped and the interior kept *verbatim* and *literal*, so a quoted +|+
46
+ # neither splits nor trips the gate (+echo 'a | b'+ is one echo). Single
47
+ # quotes decode *unconditionally* — the POSIX interior is purely literal,
48
+ # closed by the next +'+. Double quotes decode *only when verifiably
49
+ # inert*: POSIX keeps three characters live inside +"..."+ — +$+
50
+ # (expansion), the backtick (command substitution), +\+ (escaping) — so a
51
+ # span containing any {DQUOTE_LIVE} byte returns +nil+ (delegate, never
52
+ # guess), and one containing none is decoded like a single-quoted span.
53
+ # This admits the common LLM shape +grep "foo bar" f+ while keeping the
54
+ # failure mode "asks the human too often". An unbalanced quote → +nil+.
55
+ #
56
+ # Genuine quote-removal (not a placeholder rewrite) is what makes the
57
+ # spelling-based classifiers in {PassiveCommandDetector} sound — the
58
+ # cross-cutting "why the de-quoted word must be the *true* word, and why an
59
+ # allowlist tolerates a shortcut a denylist doesn't" argument lives in
60
+ # +pikuri-code/DESIGN.md+ (*The soundness hinge: true de-quoted words*).
61
+ #
62
+ # The classic mis-parse — +echo "a\"b; rm x"+, where a naive lexer closes
63
+ # the span at the escaped quote and exposes the +;+ — cannot happen: the
64
+ # scan bails on the +\+ *before* reaching the quote it escapes.
65
+ # ({.split_segments}'s simpler tracker does close a span at an escaped
66
+ # quote, but any command where that matters carries a +\+ outside single
67
+ # quotes into *some* segment, which {.scan_segment} rejects — so the
68
+ # divergence can only produce +nil+, never a mis-parsed approval.)
69
+ #
70
+ # Bash's fourth live character, +!+ (history expansion), is NOT gated
71
+ # inside the span: it fires only in interactive shells, and {Bash} always
72
+ # spawns +bash -c+ (histexpand off), so +echo "done!"+ is literal in every
73
+ # shell this reaches. Outside quotes +!+ stays forbidden.
74
+ module Tokenizer
75
+ # Any shell metacharacter or ASCII control byte that, *outside* a quoted
76
+ # span, means the segment is more than a single simple invocation ⇒
77
+ # +nil+. {.scan_segment} consults this per character only after handling
78
+ # quotes, whitespace, the {SAFE_REDIRECT} forms and {GLOB_CHARS}, so a
79
+ # safe redirect's +>+/+&+ and every quoted-argument byte never reach it.
80
+ #
81
+ # The backslash +\+ stays here (still forbidden): decoding it would
82
+ # reopen the parsing surface the quote handling avoids. The double quote
83
+ # is *not* listed — like +'+ it is handled by {.scan_segment}'s quote
84
+ # branches first. The glob/brace/tilde chars are *not* here either —
85
+ # they live in {GLOB_CHARS}, which {.scan_segment} rejects by default
86
+ # but keeps as literal word bytes under +allow_glob:+. Note +=+ is *not*
87
+ # here (+--color=never+); a leading +VAR=val+ is caught because the
88
+ # first word then isn't a bare allowed binary.
89
+ SHELL_METACHARACTERS = /[;&|<>$`()\\!#\x00-\x1f]/
90
+
91
+ # The shell *expansion* characters — pathname globbing (+*+ +?+
92
+ # +[...]+), brace expansion (+{a,b}+), tilde expansion (+~+). Split out
93
+ # of {SHELL_METACHARACTERS} because they are the one metacharacter class
94
+ # that only ever expands to *words* (filenames/paths), never to an
95
+ # operator or a new command (bash tokenizes operators *before*
96
+ # expansion and never re-scans the result). {.scan_segment} rejects
97
+ # them by default (⇒ +nil+, as before), but under +allow_glob: true+
98
+ # keeps them as literal word bytes so the caller can classify them where
99
+ # sound — see {.tokenize}'s +allow_glob:+ and the
100
+ # {PassiveCommandDetector} glob fast path.
101
+ GLOB_CHARS = /[*?\[\]{}~]/
102
+
103
+ # The three characters POSIX keeps live inside +"..."+ — +$+
104
+ # (expansion), the backtick (command substitution), +\+ (escaping,
105
+ # including of the closing +"+). {.scan_segment} returns +nil+ when any
106
+ # appears inside a double-quoted span; every other interior byte is
107
+ # literal. See the class header on why +!+ is not gated.
108
+ DQUOTE_LIVE = /[$`\\]/
109
+
110
+ # Shell control operators that merely *sequence or pipe* commands.
111
+ # {.split_segments} cuts on these outside quotes only (a quoted +|+/+;+
112
+ # is literal data). Alternation order matters: two-char +&&+/+||+ before
113
+ # single +|+ so +a || b+ splits into two segments, not three. A newline
114
+ # is a command terminator like +;+; a literal newline would sit inside a
115
+ # quoted span, where it is treated as data.
116
+ SEQUENCERS = /&&|\|\||;|\||\n/
117
+
118
+ # Defensive ceiling on sequencer-joined segments — a sanity backstop
119
+ # against pathological input, set high enough to clear a real generated
120
+ # batch (a +dpkg -S … || true+ probe per unit file across dozens of
121
+ # files). Beyond it the whole command returns +nil+.
122
+ MAX_CHAIN = 64
123
+
124
+ # A redirect fragment that writes nothing. {.scan_segment} anchors this
125
+ # at a +>+ (or +&>+) and, on a match, consumes and drops it (so the
126
+ # +>+/+&+ never reaches the {SHELL_METACHARACTERS} gate); a
127
+ # non-matching +>+ is a real redirect ⇒ +nil+. Two safe families:
128
+ #
129
+ # * fd *duplication* — +[n]>&[m]+ (+2>&1+ merges stderr into stdout):
130
+ # pure in-process fd-table manipulation, never a file.
131
+ # * *discard* to +/dev/null+ — +[n|&]>[>] /dev/null+ (append forms too).
132
+ #
133
+ # The target is pinned to *exactly* +/dev/null+ by the +(?=\s|\z)+
134
+ # lookahead, so a look-alike (+2>/dev/nullx+) does not match ⇒ +nil+, as
135
+ # does any arbitrary-file redirect. +\s*+ accepts glued or spaced. The
136
+ # leading +[0-9]*+ is the fd {.scan_segment} accumulated into the
137
+ # current word (the +2+ of +2>&1+); it trims those digits on a match.
138
+ SAFE_REDIRECT = %r{[0-9]*>&[0-9]+|(?:[0-9]|&)?>>?\s*/dev/null(?=\s|\z)}
139
+
140
+ class << self
141
+ # Parse the command into one word list per {SEQUENCERS} segment, or
142
+ # +nil+ if it can't be safely analyzed as such a chain.
143
+ #
144
+ # @param command [String] the command (a +bin/pikuri-*+ caller should
145
+ # strip any +"$ "+ echo prefix first).
146
+ # @param allow_glob [Boolean] when +true+, an unquoted {GLOB_CHARS}
147
+ # byte is kept as a literal word byte instead of rejecting the
148
+ # segment. Default +false+ (a glob ⇒ +nil+, the strict behavior).
149
+ # A quoted glob char is literal either way — this flag only affects
150
+ # *unquoted* ones. The caller opts in only where a post-expansion
151
+ # glob is provably harmless (see the {PassiveCommandDetector} glob
152
+ # fast path).
153
+ # @return [Array<Array<String>>, nil] one de-redirected, de-quoted
154
+ # word list per segment (empty list = pure-comment no-op), or +nil+
155
+ # — more than {MAX_CHAIN} segments, an unbalanced quote, a
156
+ # {DQUOTE_LIVE} byte inside double quotes, an unquoted {GLOB_CHARS}
157
+ # byte (unless +allow_glob:+), or any segment empty (a dangling
158
+ # sequencer) or not a simple invocation.
159
+ def tokenize(command, allow_glob: false)
160
+ segments = split_segments(command)
161
+ return nil if segments.nil?
162
+
163
+ words_per_segment = segments.map { |s| scan_segment(s, allow_glob) }
164
+ return nil if words_per_segment.any?(&:nil?)
165
+
166
+ words_per_segment
167
+ end
168
+
169
+ private
170
+
171
+ # Split the command into raw segment strings on {SEQUENCERS}, but only
172
+ # *outside* quotes (a quoted operator stays literal data). Quote marks
173
+ # are preserved here and removed per-segment by {.scan_segment}. This
174
+ # tracker is escape-blind (closes a span at a +\"+ bash would treat as
175
+ # escaped), safe because such a command carries a +\+ into some
176
+ # segment that {.scan_segment} rejects — see the class header.
177
+ #
178
+ # @param command [String] the command.
179
+ # @return [Array<String>, nil] the raw segments (quotes intact; empty
180
+ # strings kept, so a dangling operator yields an empty segment
181
+ # {.scan_segment} rejects), or +nil+ on an unbalanced quote or more
182
+ # than {MAX_CHAIN} segments.
183
+ def split_segments(command)
184
+ segments = []
185
+ buf = +''
186
+ quote = nil # the quote character we are inside, or nil
187
+ i = 0
188
+ while i < command.length
189
+ char = command[i]
190
+ if quote
191
+ buf << char
192
+ quote = nil if char == quote
193
+ i += 1
194
+ elsif char == "'" || char == '"'
195
+ buf << char
196
+ quote = char
197
+ i += 1
198
+ elsif (op = SEQUENCERS.match(command, i)) && op.begin(0) == i
199
+ segments << buf
200
+ buf = +''
201
+ i += op[0].length
202
+ else
203
+ buf << char
204
+ i += 1
205
+ end
206
+ end
207
+ return nil if quote
208
+
209
+ segments << buf
210
+ segments.size > MAX_CHAIN ? nil : segments
211
+ end
212
+
213
+ # Reduce one sequencer segment to its de-quoted word list, or +nil+ to
214
+ # reject the whole command. A left-to-right scan doing shell
215
+ # quote-removal (the word handed back is the true argument, +--clear+
216
+ # not +--_clear+ — see the class header). Outside quotes: whitespace
217
+ # ends a word, a {SAFE_REDIRECT} fragment is consumed and dropped, a
218
+ # {SHELL_METACHARACTERS} byte rejects, and a {GLOB_CHARS} byte rejects
219
+ # unless +allow_glob+ (then it is kept literal, like any other byte).
220
+ #
221
+ # @param segment [String] one {SEQUENCERS} segment, not yet stripped.
222
+ # @param allow_glob [Boolean] keep an unquoted {GLOB_CHARS} byte as a
223
+ # literal word byte instead of rejecting (see {.tokenize}).
224
+ # @return [Array<String>, nil] the segment's de-quoted words with any
225
+ # {SAFE_REDIRECT} fragments removed; +[]+ for a pure-comment
226
+ # segment; or +nil+ — an empty segment, an unbalanced quote, a
227
+ # {DQUOTE_LIVE} byte inside double quotes, a {SHELL_METACHARACTERS}
228
+ # byte, an unquoted {GLOB_CHARS} byte (unless +allow_glob+), or a
229
+ # redirect that is not write-nothing.
230
+ def scan_segment(segment, allow_glob = false)
231
+ command = segment.strip
232
+ return nil if command.empty?
233
+
234
+ # A segment starting with +#+ is a shell comment — a no-op. Sound
235
+ # because every segment boundary is a command separator, where +#+
236
+ # genuinely opens a comment; a mid-segment +#+ (+ls #+) still trips
237
+ # the gate below.
238
+ return [] if command.start_with?('#')
239
+
240
+ words = []
241
+ word = nil # current word buffer; nil between words
242
+ quote = nil # the quote character we are inside, or nil
243
+ i = 0
244
+ while i < command.length
245
+ char = command[i]
246
+ if quote == "'"
247
+ # Single-quote interior: literal, never a separator/metachar.
248
+ char == "'" ? (quote = nil) : (word = (word || +'') << char)
249
+ i += 1
250
+ elsif quote == '"'
251
+ # Double-quote interior: literal too — unless one of the
252
+ # POSIX-live bytes appears, which rejects the segment.
253
+ if char == '"'
254
+ quote = nil
255
+ elsif DQUOTE_LIVE.match?(char)
256
+ return nil
257
+ else
258
+ word = (word || +'') << char
259
+ end
260
+ i += 1
261
+ elsif char == "'" || char == '"'
262
+ word ||= +'' # an opening quote starts a (maybe empty) word
263
+ quote = char
264
+ i += 1
265
+ elsif char == ' ' || char == "\t"
266
+ words << word unless word.nil?
267
+ word = nil
268
+ i += 1
269
+ elsif char == '>' || (char == '&' && command[i + 1] == '>')
270
+ # A redirect. Anchor {SAFE_REDIRECT} at the fd prefix (the
271
+ # trailing digits of the current word, e.g. the +2+ of
272
+ # +2>&1+), accepting only the write-nothing forms; anything
273
+ # else (a real target) returns +nil+. The fd digits belonged
274
+ # to the redirect, not the word, so trim them off.
275
+ digits = char == '>' && word ? word[/\d*\z/].length : 0
276
+ redirect = SAFE_REDIRECT.match(command, i - digits)
277
+ return nil unless redirect && redirect.begin(0) == i - digits
278
+
279
+ if digits.positive?
280
+ word = word[0...-digits]
281
+ word = nil if word.empty?
282
+ end
283
+ words << word unless word.nil?
284
+ word = nil
285
+ i = redirect.end(0)
286
+ elsif GLOB_CHARS.match?(char)
287
+ # An unquoted glob/brace/tilde byte. bash would *expand* it;
288
+ # reject unless the caller opted into keeping it literal (then
289
+ # it is treated exactly like any ordinary word byte below).
290
+ return nil unless allow_glob
291
+
292
+ word = (word || +'') << char
293
+ i += 1
294
+ elsif SHELL_METACHARACTERS.match?(char)
295
+ return nil
296
+ else
297
+ word = (word || +'') << char
298
+ i += 1
299
+ end
300
+ end
301
+ return nil if quote
302
+
303
+ words << word unless word.nil?
304
+ words.empty? ? nil : words
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end