diff --git a/specfile/sanitizer.py b/specfile/sanitizer.py index fad809c..9c2e505 100644 --- a/specfile/sanitizer.py +++ b/specfile/sanitizer.py @@ -91,59 +91,6 @@ _LUA_STRING_LITERAL_RE = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') -def _strip_lua_comments(code): - """Strip Lua comments while preserving string literals. - - Processes code left-to-right so that ``--`` inside a quoted string - is never mistaken for a comment start. - """ - result = [] - i = 0 - while i < len(code): - if code[i] in ('"', "'"): - quote = code[i] - result.append(code[i]) - i += 1 - while i < len(code) and code[i] != quote: - if code[i] == "\\" and i + 1 < len(code): - result.append(code[i : i + 2]) - i += 2 - else: - result.append(code[i]) - i += 1 - if i < len(code): - result.append(code[i]) - i += 1 - elif code[i : i + 2] == "--": - j = i + 2 - if j < len(code) and code[j] == "[": - level = 0 - k = j + 1 - while k < len(code) and code[k] == "=": - level += 1 - k += 1 - if k < len(code) and code[k] == "[": - close = "]" + "=" * level + "]" - end = code.find(close, k + 1) - if end != -1: - i = end + len(close) - else: - i = len(code) - result.append(" ") - continue - end = code.find("\n", i) - if end != -1: - result.append(" ") - i = end - else: - result.append(" ") - i = len(code) - else: - result.append(code[i]) - i += 1 - return "".join(result) - - _UNSAFE_LUA_BRACKET_RE = re.compile(r"\[\s*(?![#\d])") _UNSAFE_LUA_STRING_CONTENT_RE = re.compile( @@ -154,67 +101,6 @@ def _strip_lua_comments(code): _EXPRESSION_LUA_PREFIX_RE = re.compile(r"lua\s*:") -def _decode_lua_escapes(s): - """Decode all Lua string escape sequences to their character values.""" - _SIMPLE = { - "a": "\a", - "b": "\b", - "f": "\f", - "n": "\n", - "r": "\r", - "t": "\t", - "v": "\v", - "\\": "\\", - '"': '"', - "'": "'", - } - result = [] - i = 0 - while i < len(s): - if s[i] != "\\" or i + 1 >= len(s): - result.append(s[i]) - i += 1 - continue - c = s[i + 1] - if c in _SIMPLE: - result.append(_SIMPLE[c]) - i += 2 - elif c == "z": - i += 2 - while i < len(s) and s[i] in " \t\n\r": - i += 1 - elif c == "x" and i + 3 < len(s): - try: - result.append(chr(int(s[i + 2 : i + 4], 16))) - i += 4 - except ValueError: - result.append(s[i]) - i += 1 - elif c == "u" and i + 2 < len(s) and s[i + 2] == "{": - end = s.find("}", i + 3) - if end != -1: - try: - result.append(chr(int(s[i + 3 : end], 16))) - i = end + 1 - except (ValueError, OverflowError): - result.append(s[i]) - i += 1 - else: - result.append(s[i]) - i += 1 - elif c.isdigit(): - j = i + 1 - while j < len(s) and j < i + 4 and s[j].isdigit(): - j += 1 - num = int(s[i + 1 : j]) - result.append(chr(num % 256)) - i = j - else: - result.append(s[i]) - i += 1 - return "".join(result) - - _UNSAFE_LUA_IDENTIFIERS = frozenset( { "_G", @@ -287,7 +173,7 @@ def sanitize_shell_expansion(body: str) -> str: or Lua expressions. Covered patterns are: - Substring extraction: - `%(c=%{commit}; echo ${c:0:7})` → `%{sub %{commit}, 1, 7}` + `%(c=%{commit}; echo ${c:0:7})` → `%{sub %{commit} 1 7}` - Bash string replacement: `%(v=%{version}; echo ${v//./_})` → `%{lua: print((rpm.expand("%{version}"):gsub("%.", "_")))}` @@ -331,7 +217,7 @@ def sanitize_shell_expansion(body: str) -> str: `%(test "%{_libdir}" != "%{_prefix}/lib" && echo 1 || echo 0)` → `%{lua: print(rpm.expand("%{_libdir}") ~= rpm.expand("%{_prefix}/lib") and "1" or "0")}` - Printf truncation: - `%(printf %%.7s %commit)` → `%{sub %{commit}, 1, 7}` + `%(printf %%.7s %commit)` → `%{sub %{commit} 1 7}` - Printf float formatting: `%(LANG=C printf "%.4f" %{cpan_ver})` → `%{lua: print(string.format("%%.4f", tonumber(rpm.expand("%{cpan_ver}"))))}` @@ -567,8 +453,8 @@ def convert_string_op(expr, cmd): mode, start, end, delim = cut if mode == "bytes": if end is not None: - return f"%{{sub {expr}, {start}, {end}}}" - return f"%{{sub {expr}, {start}}}" + return f"%{{sub {expr} {start} {end}}}" + return f"%{{sub {expr} {start}}}" elif mode == "field": return build_lua_field(expr, delim, start) elif mode == "range": @@ -700,14 +586,14 @@ def convert_glob_removal(expr, op, pat): start = offset + 1 if length_raw is not None: if length_raw.isdigit(): - return f"%{{sub {expr}, {start}, {offset + int(length_raw)}}}" + return f"%{{sub {expr} {start} {offset + int(length_raw)}}}" length_macro = normalize_macro(length_raw) if length_macro is not None: if offset == 0: - return f"%{{sub {expr}, {start}, {length_macro}}}" - return f"%{{sub {expr}, {start}, %[{length_macro} + {offset}]}}" + return f"%{{sub {expr} {start} {length_macro}}}" + return f"%{{sub {expr} {start} %[{length_macro} + {offset}]}}" else: - return f"%{{sub {expr}, {start}}}" + return f"%{{sub {expr} {start}}}" # --- var=macro; echo ${var//PAT/REPL} → Lua gsub --- m = _RE_BASH_REPLACE.match(body) @@ -850,13 +736,13 @@ def convert_glob_removal(expr, op, pat): f' and "{lua_string_escape(a)}" or "{lua_string_escape(b)}")}}' ) - # --- printf %.Ns MACRO → %{sub MACRO, 1, N} --- + # --- printf %.Ns MACRO → %{sub MACRO 1 N} --- m = _RE_PRINTF_TRUNC.match(body) if m: n = int(m.group(1)) expr = normalize_macro(m.group(2)) if expr is not None: - return f"%{{sub {expr}, 1, {n}}}" + return f"%{{sub {expr} 1 {n}}}" # --- printf %.Nf MACRO → Lua string.format --- m = _RE_PRINTF_FLOAT.match(body) @@ -890,6 +776,118 @@ def convert_glob_removal(expr, op, pat): @staticmethod def is_lua_safe(code): + def strip_lua_comments(code): + """Strip Lua comments while preserving string literals. + + Processes code left-to-right so that ``--`` inside a quoted string + is never mistaken for a comment start. + """ + result = [] + i = 0 + while i < len(code): + if code[i] in ('"', "'"): + quote = code[i] + result.append(code[i]) + i += 1 + while i < len(code) and code[i] != quote: + if code[i] == "\\" and i + 1 < len(code): + result.append(code[i : i + 2]) + i += 2 + else: + result.append(code[i]) + i += 1 + if i < len(code): + result.append(code[i]) + i += 1 + elif code[i : i + 2] == "--": + j = i + 2 + if j < len(code) and code[j] == "[": + level = 0 + k = j + 1 + while k < len(code) and code[k] == "=": + level += 1 + k += 1 + if k < len(code) and code[k] == "[": + close = "]" + "=" * level + "]" + end = code.find(close, k + 1) + if end != -1: + i = end + len(close) + else: + i = len(code) + result.append(" ") + continue + end = code.find("\n", i) + if end != -1: + result.append(" ") + i = end + else: + result.append(" ") + i = len(code) + else: + result.append(code[i]) + i += 1 + return "".join(result) + + def decode_lua_escapes(s): + """Decode all Lua string escape sequences to their character values.""" + _SIMPLE = { + "a": "\a", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", + "\\": "\\", + '"': '"', + "'": "'", + } + result = [] + i = 0 + while i < len(s): + if s[i] != "\\" or i + 1 >= len(s): + result.append(s[i]) + i += 1 + continue + c = s[i + 1] + if c in _SIMPLE: + result.append(_SIMPLE[c]) + i += 2 + elif c == "z": + i += 2 + while i < len(s) and s[i] in " \t\n\r": + i += 1 + elif c == "x" and i + 3 < len(s): + try: + result.append(chr(int(s[i + 2 : i + 4], 16))) + i += 4 + except ValueError: + result.append(s[i]) + i += 1 + elif c == "u" and i + 2 < len(s) and s[i + 2] == "{": + end = s.find("}", i + 3) + if end != -1: + try: + result.append(chr(int(s[i + 3 : end], 16))) + i = end + 1 + except (ValueError, OverflowError): + result.append(s[i]) + i += 1 + else: + result.append(s[i]) + i += 1 + elif c.isdigit(): + j = i + 1 + while j < len(s) and j < i + 4 and s[j].isdigit(): + j += 1 + num = int(s[i + 1 : j]) + result.append(chr(num % 256)) + i = j + else: + result.append(s[i]) + i += 1 + return "".join(result) + def has_safe_format_specs(fmt): """ Check that a format string only uses safe specifiers. @@ -903,13 +901,13 @@ def has_safe_format_specs(fmt): cleaned = _SAFE_FORMAT_SPEC_RE.sub("", expanded) return "%" not in cleaned - stripped = _strip_lua_comments(code) + stripped = strip_lua_comments(code) string_spans = [] for m in _LUA_STRING_LITERAL_RE.finditer(stripped): content = m.group(0)[1:-1] if _UNSAFE_LUA_STRING_CONTENT_RE.search(content): return False - decoded = _decode_lua_escapes(content) + decoded = decode_lua_escapes(content) if _UNSAFE_LUA_STRING_CONTENT_RE.search(decoded): return False if decoded.endswith("%"): diff --git a/tests/unit/test_sanitizer.py b/tests/unit/test_sanitizer.py index 4baa826..b41852d 100644 --- a/tests/unit/test_sanitizer.py +++ b/tests/unit/test_sanitizer.py @@ -11,43 +11,43 @@ [ ( "c=%{commit}; echo ${c:0:7}", - "%{sub %{commit}, 1, 7}", + "%{sub %{commit} 1 7}", ), ( "c=%{commit};echo ${c:0:7}", - "%{sub %{commit}, 1, 7}", + "%{sub %{commit} 1 7}", ), ( "c=%{commit0}; echo ${c:0:7}", - "%{sub %{commit0}, 1, 7}", + "%{sub %{commit0} 1 7}", ), ( "foo=%{version}; echo ${foo:0:5}", - "%{sub %{version}, 1, 5}", + "%{sub %{version} 1 5}", ), ( "foo=%{version}; echo ${foo:6}", - "%{sub %{version}, 7}", + "%{sub %{version} 7}", ), ( "l=%{_lib}; echo ${l:3}", - "%{sub %{_lib}, 4}", + "%{sub %{_lib} 4}", ), ( "c=%{version}; echo ${c:12:4}", - "%{sub %{version}, 13, 16}", + "%{sub %{version} 13 16}", ), ( "c=%{commit}; echo ${c:0:%{commit_abbrev}}", - "%{sub %{commit}, 1, %{commit_abbrev}}", + "%{sub %{commit} 1 %{commit_abbrev}}", ), ( 'c="%{git_commit}"; echo "${c:0:8}"', - "%{sub %{git_commit}, 1, 8}", + "%{sub %{git_commit} 1 8}", ), ( "n=%{modname}; echo ${n:0:1}", - "%{sub %{modname}, 1, 1}", + "%{sub %{modname} 1 1}", ), ], ) @@ -243,15 +243,15 @@ def test_pipe_to_cut(body, expected): [ ( "echo %{git_commit} | cut -c -8", - "%{sub %{git_commit}, 1, 8}", + "%{sub %{git_commit} 1 8}", ), ( "echo %{gitcommit} | cut -c 1-8", - "%{sub %{gitcommit}, 1, 8}", + "%{sub %{gitcommit} 1 8}", ), ( "echo %{snapshot_rev} | cut -c1-6", - "%{sub %{snapshot_rev}, 1, 6}", + "%{sub %{snapshot_rev} 1 6}", ), ], ) @@ -397,7 +397,7 @@ def test_pipe_to_awk(body, expected): ), ( "cut -b -7 <<< %{emacscommit}", - "%{sub %{emacscommit}, 1, 7}", + "%{sub %{emacscommit} 1 7}", ), ( "tr -d . <<< %{version}", @@ -544,7 +544,7 @@ def test_empty_test(): def test_printf_truncation(): assert ( Sanitizer.sanitize_shell_expansion("printf %%.7s %commit") - == "%{sub %{commit}, 1, 7}" + == "%{sub %{commit} 1 7}" ) @@ -585,7 +585,7 @@ def test_echo_concat(body, expected): def test_cut_bytes_with_macro_offset(): assert ( Sanitizer.sanitize_shell_expansion("cut -b %{rmprefix}- <<<'%{_bindir}'") - == "%{sub %{_bindir}, %{rmprefix}}" + == "%{sub %{_bindir} %{rmprefix}}" ) @@ -1310,12 +1310,13 @@ def test_lua_unsafe_char_escape_bypass(code): def test_decode_lua_escapes_out_of_range(): """Decimal escapes > 255 are truncated to fit in a byte, matching Lua behavior.""" - from specfile.sanitizer import _decode_lua_escapes - - assert _decode_lua_escapes(r"\300") == "," # 300 % 256 == 44 == ',' - assert _decode_lua_escapes(r"\256") == "\x00" # 256 % 256 == 0 - assert _decode_lua_escapes(r"\512") == "\x00" # 512 % 256 == 0 - assert _decode_lua_escapes(r"\293") == "%" # 293 % 256 == 37 == '%' + # \293 decodes to chr(293 % 256) == chr(37) == '%', trailing '%' is unsafe + assert not Sanitizer.is_lua_safe(r'print("\293")') + # \300 decodes to chr(300 % 256) == chr(44) == ',', safe + assert Sanitizer.is_lua_safe(r'print("\300")') + # \256 and \512 decode to chr(0), safe + assert Sanitizer.is_lua_safe(r'print("\256")') + assert Sanitizer.is_lua_safe(r'print("\512")') def test_sanitize_depth_limit():