Skip to content

Commit 5bab125

Browse files
committed
Implement sanitization of multi-pipe shell expansions
Signed-off-by: Nikola Forró <nforro@redhat.com> Assisted-by: Claude Opus 4.6 via Claude Code
1 parent 2dc72c0 commit 5bab125

2 files changed

Lines changed: 168 additions & 2 deletions

File tree

specfile/sanitizer.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,117 @@ def build_lua_char_class(chars):
446446
ordered = ordered.replace("-", "") + "-"
447447
return f"[{ordered}]"
448448

449+
def _stage_to_lua_fragment(stage):
450+
"""Convert a single pipeline stage to Lua code that transforms 'v'."""
451+
cut = parse_cut(stage)
452+
if cut:
453+
mode, start, end, delim = cut
454+
if mode == "bytes":
455+
return None
456+
elif mode == "field":
457+
esc = lua_string_escape(lua_pattern_escape(delim))
458+
return (
459+
f"do local t={{}} "
460+
f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end '
461+
f'v=t[{start}] or "" end'
462+
)
463+
elif mode == "range":
464+
esc = lua_string_escape(lua_pattern_escape(delim))
465+
return (
466+
f"do local t={{}} "
467+
f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end '
468+
f'v=table.concat(t,"{lua_string_escape(delim)}",'
469+
f"{start},math.min(#t,{end})) end"
470+
)
471+
if _RE_TR_LOWER.match(stage):
472+
return "v=v:lower()"
473+
if _RE_TR_UPPER.match(stage):
474+
return "v=v:upper()"
475+
m = _RE_TR_DELETE.match(stage)
476+
if m:
477+
chars = m.group(1)
478+
if len(chars) == 1:
479+
pat = lua_pattern_escape(chars)
480+
else:
481+
pat = build_lua_char_class(chars)
482+
return f'v=(v:gsub("{lua_string_escape(pat)}", ""))'
483+
m = _RE_TR_DELETE_BARE.match(stage)
484+
if m:
485+
pat = lua_string_escape(lua_pattern_escape(m.group(1)))
486+
return f'v=(v:gsub("{pat}", ""))'
487+
m = _RE_TR_REPLACE.match(stage)
488+
if m:
489+
pat = lua_string_escape(lua_pattern_escape(m.group(1)))
490+
repl = lua_string_escape(lua_gsub_repl_escape(m.group(2)))
491+
return f'v=(v:gsub("{pat}", "{repl}"))'
492+
m = _RE_AWK_F.match(stage)
493+
if m:
494+
delim = m.group(1)
495+
print_args = m.group(2)
496+
parts = _RE_AWK_FIELDS.findall(print_args)
497+
if parts:
498+
if not all(is_safe_for_expand(sep) for _, sep in parts if sep):
499+
return None
500+
lua_parts = []
501+
for field_num, separator in parts:
502+
if field_num:
503+
lua_parts.append(f'(t[{field_num}] or "")')
504+
elif separator is not None:
505+
lua_parts.append(f'"{lua_string_escape(separator)}"')
506+
if lua_parts:
507+
lua_expr = " .. ".join(lua_parts)
508+
esc = lua_string_escape(lua_pattern_escape(delim))
509+
return (
510+
f"do local t={{}} "
511+
f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end '
512+
f"v={lua_expr} end"
513+
)
514+
substs = parse_sed_substs(stage)
515+
if substs:
516+
if all(is_safe_for_expand(repl) for _, repl, _ in substs):
517+
parts = []
518+
for pattern, repl, is_global in substs:
519+
esc_pat = lua_string_escape(sed_pattern_to_lua(pattern))
520+
esc_repl = lua_string_escape(lua_gsub_repl_escape(repl))
521+
count_arg = "" if is_global else ", 1"
522+
parts.append(
523+
f'v=(v:gsub("{esc_pat}", "{esc_repl}"{count_arg}))'
524+
)
525+
return " ".join(parts)
526+
return None
527+
449528
def convert_string_op(expr, cmd):
529+
# Handle pipelines: split cmd into stages and compose Lua
530+
try:
531+
tokens = shlex.split(cmd, posix=False)
532+
except ValueError:
533+
tokens = None
534+
if tokens:
535+
stages = []
536+
current = []
537+
for token in tokens:
538+
if token == "|":
539+
if current:
540+
stages.append(" ".join(current))
541+
current = []
542+
else:
543+
current.append(token)
544+
if current:
545+
stages.append(" ".join(current))
546+
if len(stages) > 1:
547+
esc_expr = lua_string_escape(expr)
548+
fragments = []
549+
for stage in stages:
550+
fragment = _stage_to_lua_fragment(stage)
551+
if fragment is None:
552+
return None
553+
fragments.append(fragment)
554+
lua_code = f'local v=rpm.expand("{esc_expr}")'
555+
for fragment in fragments:
556+
lua_code += f" {fragment}"
557+
lua_code += " print(v)"
558+
return f"%{{lua:{lua_code}}}"
559+
450560
# -- cut --
451561
cut = parse_cut(cmd)
452562
if cut:

tests/unit/test_sanitizer.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ def test_pipe_to_cut_bytes(body, expected):
290290
"echo %{date} | tr -d -",
291291
'%{lua:print((rpm.expand("%{date}"):gsub("%-", "")))}',
292292
),
293+
(
294+
"echo %{version} | tr '|' '.'",
295+
'%{lua:print((rpm.expand("%{version}"):gsub("|", ".")))}',
296+
),
293297
],
294298
)
295299
def test_pipe_to_tr(body, expected):
@@ -337,8 +341,10 @@ def test_chained_sed():
337341
assert Sanitizer.sanitize_shell_expansion(
338342
"echo %{tag} | sed -e 's|.00$||' | sed -e 's|\\.||g'"
339343
) == (
340-
'%{lua:local v=(rpm.expand("%{tag}"):gsub(".00$", "", 1))'
341-
' print((v:gsub("%.", "")))}'
344+
'%{lua:local v=rpm.expand("%{tag}")'
345+
' v=(v:gsub(".00$", "", 1))'
346+
' v=(v:gsub("%.", ""))'
347+
" print(v)}"
342348
)
343349

344350

@@ -351,6 +357,56 @@ def test_chained_sed_rpm_escaped():
351357
)
352358

353359

360+
@pytest.mark.parametrize(
361+
"body, expected",
362+
[
363+
(
364+
"echo %{version} | cut -d. -f1 | cut -d~ -f1",
365+
'%{lua:local v=rpm.expand("%{version}")'
366+
' do local t={} for f in v:gmatch("[^%.]+") do t[#t+1]=f end v=t[1] or "" end'
367+
' do local t={} for f in v:gmatch("[^~]+") do t[#t+1]=f end v=t[1] or "" end'
368+
" print(v)}",
369+
),
370+
(
371+
"echo %{version} | cut -d. -f1 | tr A B",
372+
'%{lua:local v=rpm.expand("%{version}")'
373+
' do local t={} for f in v:gmatch("[^%.]+") do t[#t+1]=f end v=t[1] or "" end'
374+
' v=(v:gsub("A", "B"))'
375+
" print(v)}",
376+
),
377+
(
378+
"echo %{version} | tr '~' '.' | cut -d. -f1",
379+
'%{lua:local v=rpm.expand("%{version}")'
380+
' v=(v:gsub("~", "."))'
381+
' do local t={} for f in v:gmatch("[^%.]+") do t[#t+1]=f end v=t[1] or "" end'
382+
" print(v)}",
383+
),
384+
(
385+
"echo %{version} | cut -d. -f1 | unsupported_command",
386+
"%{nil}",
387+
),
388+
],
389+
)
390+
def test_piped_commands(body, expected):
391+
assert Sanitizer.sanitize_shell_expansion(body) == expected
392+
393+
394+
@pytest.mark.parametrize(
395+
"body, expected",
396+
[
397+
(
398+
"c=%{version}; echo $c | cut -d. -f1 | cut -d~ -f1",
399+
'%{lua:local v=rpm.expand("%{version}")'
400+
' do local t={} for f in v:gmatch("[^%.]+") do t[#t+1]=f end v=t[1] or "" end'
401+
' do local t={} for f in v:gmatch("[^~]+") do t[#t+1]=f end v=t[1] or "" end'
402+
" print(v)}",
403+
),
404+
],
405+
)
406+
def test_var_piped_commands(body, expected):
407+
assert Sanitizer.sanitize_shell_expansion(body) == expected
408+
409+
354410
@pytest.mark.parametrize(
355411
"body, expected",
356412
[

0 commit comments

Comments
 (0)