Skip to content

Commit 8ac2b63

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 8ac2b63

2 files changed

Lines changed: 167 additions & 2 deletions

File tree

specfile/sanitizer.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,124 @@ 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+
if not str(start).isdigit():
456+
return None
457+
if end is not None:
458+
if not str(end).isdigit():
459+
return None
460+
return f"v=v:sub({start},{end})"
461+
return f"v=v:sub({start})"
462+
elif mode == "field":
463+
esc = lua_string_escape(lua_pattern_escape(delim))
464+
return (
465+
f"do local t={{}} "
466+
f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end '
467+
f'v=t[{start}] or "" end'
468+
)
469+
elif mode == "range":
470+
esc = lua_string_escape(lua_pattern_escape(delim))
471+
return (
472+
f"do local t={{}} "
473+
f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end '
474+
f'v=table.concat(t,"{lua_string_escape(delim)}",'
475+
f"{start},math.min(#t,{end})) end"
476+
)
477+
if _RE_TR_LOWER.match(stage):
478+
return "v=v:lower()"
479+
if _RE_TR_UPPER.match(stage):
480+
return "v=v:upper()"
481+
m = _RE_TR_DELETE.match(stage)
482+
if m:
483+
chars = m.group(1)
484+
if len(chars) == 1:
485+
pat = lua_pattern_escape(chars)
486+
else:
487+
pat = build_lua_char_class(chars)
488+
return f'v=(v:gsub("{lua_string_escape(pat)}", ""))'
489+
m = _RE_TR_DELETE_BARE.match(stage)
490+
if m:
491+
pat = lua_string_escape(lua_pattern_escape(m.group(1)))
492+
return f'v=(v:gsub("{pat}", ""))'
493+
m = _RE_TR_REPLACE.match(stage)
494+
if m:
495+
pat = lua_string_escape(lua_pattern_escape(m.group(1)))
496+
repl = lua_string_escape(lua_gsub_repl_escape(m.group(2)))
497+
return f'v=(v:gsub("{pat}", "{repl}"))'
498+
m = _RE_AWK_F.match(stage)
499+
if m:
500+
delim = m.group(1)
501+
print_args = m.group(2)
502+
parts = _RE_AWK_FIELDS.findall(print_args)
503+
if parts:
504+
if not all(is_safe_for_expand(sep) for _, sep in parts if sep):
505+
return None
506+
lua_parts = []
507+
for field_num, separator in parts:
508+
if field_num:
509+
lua_parts.append(f'(t[{field_num}] or "")')
510+
elif separator is not None:
511+
lua_parts.append(f'"{lua_string_escape(separator)}"')
512+
if lua_parts:
513+
lua_expr = " .. ".join(lua_parts)
514+
esc = lua_string_escape(lua_pattern_escape(delim))
515+
return (
516+
f"do local t={{}} "
517+
f'for f in v:gmatch("[^{esc}]+") do t[#t+1]=f end '
518+
f"v={lua_expr} end"
519+
)
520+
substs = parse_sed_substs(stage)
521+
if substs:
522+
if all(is_safe_for_expand(repl) for _, repl, _ in substs):
523+
parts = []
524+
for pattern, repl, is_global in substs:
525+
esc_pat = lua_string_escape(sed_pattern_to_lua(pattern))
526+
esc_repl = lua_string_escape(lua_gsub_repl_escape(repl))
527+
count_arg = "" if is_global else ", 1"
528+
parts.append(
529+
f'v=(v:gsub("{esc_pat}", "{esc_repl}"{count_arg}))'
530+
)
531+
return " ".join(parts)
532+
return None
533+
449534
def convert_string_op(expr, cmd):
535+
# Handle pipelines: split cmd into stages and compose Lua
536+
try:
537+
tokens = shlex.split(cmd)
538+
except ValueError:
539+
tokens = None
540+
if tokens:
541+
stages = []
542+
current = []
543+
for token in tokens:
544+
if token == "|":
545+
if current:
546+
stages.append(shlex.join(current))
547+
current = []
548+
else:
549+
current.append(token)
550+
if current:
551+
stages.append(" ".join(current))
552+
if len(stages) > 1:
553+
esc_expr = lua_string_escape(expr)
554+
fragments = []
555+
for stage in stages:
556+
fragment = _stage_to_lua_fragment(stage)
557+
if fragment is None:
558+
break
559+
fragments.append(fragment)
560+
else:
561+
lua_code = f'local v=rpm.expand("{esc_expr}")'
562+
for fragment in fragments:
563+
lua_code += f" {fragment}"
564+
lua_code += " print(v)"
565+
return f"%{{lua:{lua_code}}}"
566+
450567
# -- cut --
451568
cut = parse_cut(cmd)
452569
if cut:

tests/unit/test_sanitizer.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,10 @@ def test_chained_sed():
337337
assert Sanitizer.sanitize_shell_expansion(
338338
"echo %{tag} | sed -e 's|.00$||' | sed -e 's|\\.||g'"
339339
) == (
340-
'%{lua:local v=(rpm.expand("%{tag}"):gsub(".00$", "", 1))'
341-
' print((v:gsub("%.", "")))}'
340+
'%{lua:local v=rpm.expand("%{tag}")'
341+
' v=(v:gsub(".00$", "", 1))'
342+
' v=(v:gsub("%.", ""))'
343+
" print(v)}"
342344
)
343345

344346

@@ -351,6 +353,52 @@ def test_chained_sed_rpm_escaped():
351353
)
352354

353355

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

0 commit comments

Comments
 (0)