From b2145641c0208e5eea5f33f2a5b69337da71ee70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Thu, 7 May 2026 21:06:51 +0200 Subject: [PATCH] Fix a regression wrt option parsing for macros with backslash-escaped newlines in argument list When macro arguments span across multiple lines with backslash-escaped newlines, e.g.: %{pyproject_buildrequires \ -x pathops \ -x autohint \ -x json \ -x repacker} rpm.define() for positional args would fail with: Macro %__pyproject_positional_args has empty body error: lua script failed: .../pyproject_getopt.lua:252: error defining macro Because the "-x pathops" would be consumed by value parsing and all the positional arguments would be just backslash-escaped newlines. Defining the %__pyproject_positional_args macro to whitespace is not possible, unless properly quoted. Filter out line-continuation artifacts (tokens containing only whitespace, backslashes, and newlines) before parsing, so they cannot be misinterpreted as option values or positional args. Extract a shared quote_value() function for safe embedding of values in rpm.define() calls. Additionally, RPM may embed newlines within tokens when macros use %{expand:} with literal newlines. Such tokens are split on newlines before parsing. The regression impacted parsing of 14 Fedora Rawhide spec files: - dcm2niix - fontmake - fonttools - libarrow - python-django5 - python-django6 - python-pydantic-extra-types - python-pydantic-settings - python-pylero - python-pynetdicom - python-tablib - python-trimesh - python-urllib3 - scapy And all are now fixed. No other Fedora Rawhide spec files fail to parse due to the long option support after this fix. Assisted-By: Claude Opus 4.6 (cherry picked from Fedora commit 2acda15168b468b195a1ae9202c58012b8c2f6f3) --- pyproject-rpm-macros.spec | 5 ++- pyproject_getopt.lua | 64 ++++++++++++++++++++++++++++++--------- test_pyproject_getopt.lua | 22 ++++++++++++++ 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/pyproject-rpm-macros.spec b/pyproject-rpm-macros.spec index 2fdb4ef..cd88d8f 100644 --- a/pyproject-rpm-macros.spec +++ b/pyproject-rpm-macros.spec @@ -14,7 +14,7 @@ License: MIT # Increment Y and reset Z when new macros or features are added # Increment Z when this is a bugfix or a cosmetic change # Dropping support for EOL Fedoras is *not* considered a breaking change -Version: 1.22.0 +Version: 1.22.1 Release: 1%{?dist} # Macro files @@ -203,6 +203,9 @@ export HOSTNAME="rpmbuild" # to speedup tox in network-less mock, see rhbz#1856 %changelog +* Thu May 07 2026 Miro Hrončok - 1.22.1-1 +- Fix a regression wrt option parsing for macros with backslash-escaped newlines in argument list + * Thu May 07 2026 Miro Hrončok - 1.22.0-1 - Add long option support for all public parametric macros - E.g. %%pyproject_buildrequires --no-runtime is equivalent to %%pyproject_buildrequires -R diff --git a/pyproject_getopt.lua b/pyproject_getopt.lua index 569415e..26ff7e9 100644 --- a/pyproject_getopt.lua +++ b/pyproject_getopt.lua @@ -46,6 +46,29 @@ function M.rpm_args() end +-- RPM may embed newlines within tokens (from %{expand:} or line continuations). +-- Split such tokens on newlines, strip trailing backslashes and whitespace +-- (from \-newline continuations inside %{macro:} syntax), and discard +-- whitespace/backslash-only fragments. +-- Intentional empty strings (e.g. from -d "") are preserved. +local function normalize_tokens(tokens) + local filtered = {} + for _, t in ipairs(tokens) do + if t:find("\n") then + for part in (t .. "\n"):gmatch("([^\n]*)\n") do + local stripped = part:gsub("[%s\\]+$", "") + if stripped ~= "" then + filtered[#filtered + 1] = stripped + end + end + elseif not t:find("^[%s\\]+$") then + filtered[#filtered + 1] = t + end + end + return filtered +end + + local function build_lookup(opt_spec) local by_short = {} local by_long = {} @@ -196,25 +219,37 @@ end -- On older RPM, %{quote:} leaks \x1f into shell commands, so we skip quoting there. M._use_quote = rpm.vercmp(rpm.expand("0%{?rpmversion}"), "4.19.90") >= 0 +-- Quote a single value for safe embedding in an rpm.define() call. +-- On RPM 4.20+, empty values or values with spaces are wrapped in %{quote:}. +-- On older RPM, empty values are represented as %{nil}, values with spaces are verbatim. +local function quote_value(v) + if M._use_quote and (v == "" or v:find("%s")) then + return "%{quote:" .. v .. "}" + elseif v == "" then + return "%{nil}" + else + return v + end +end + +-- Apply quote_value to each element, return a new table. +local function quote_values(values) + local parts = {} + for _, v in ipairs(values) do + parts[#parts + 1] = quote_value(v) + end + return parts +end + -- Define %__pyproject_opt_{short} for each found option. -- Flags get %{nil} (defined but empty), value options get each individual -- value joined by separator. --- On RPM 4.20+ empty values or values with spaces are wrapped in %{quote:} before joining. --- On older RPM, empty values are represented as %{nil}, values with spaces are verbatim. +-- See the quote_value function about how values are quoted. local function define_macros(found, opt_spec) for _, spec in ipairs(opt_spec) do if found[spec.short] then if spec.value then - local parts = {} - for i, v in ipairs(found[spec.short]) do - if M._use_quote and (v == "" or v:find("%s")) then - parts[i] = "%{quote:" .. v .. "}" - elseif v == "" then - parts[i] = "%{nil}" - else - parts[i] = v - end - end + local parts = quote_values(found[spec.short]) rpm.define("__pyproject_opt_" .. spec.short .. " " .. table.concat(parts, spec.separator)) else rpm.define("__pyproject_opt_" .. spec.short .. " %{nil}") @@ -231,7 +266,7 @@ end -- exclusion_rules: optional list of {short_char, {conflicting_short_chars...}} pairs. -- tokens and macro_name default to the current RPM macro's arguments and name. function M.getopt(opt_spec, exclusion_rules, tokens, macro_name) - tokens = tokens or M.rpm_args() + tokens = normalize_tokens(tokens or M.rpm_args()) macro_name = macro_name or rpm.expand("%0") local by_short, by_long = build_lookup(opt_spec) cleanup_macros(opt_spec) @@ -249,7 +284,8 @@ function M.getopt(opt_spec, exclusion_rules, tokens, macro_name) define_macros(result.found, opt_spec) if #result.positional > 0 then - rpm.define("__pyproject_positional_args " .. table.concat(result.positional, " ")) + local parts = quote_values(result.positional) + rpm.define("__pyproject_positional_args " .. table.concat(parts, " ")) end end diff --git a/test_pyproject_getopt.lua b/test_pyproject_getopt.lua index 5572a30..efe700a 100644 --- a/test_pyproject_getopt.lua +++ b/test_pyproject_getopt.lua @@ -268,6 +268,28 @@ function M.test_positional_with_spaces() assert_positional("module with spaces other") end +function M.test_empty_positional_args() + pg.getopt(SAVE_FILES_SPEC, nil, {"", "foo", ""}, "test") + assert_positional(" foo ") +end + +function M.test_whitespace_positional_args() + pg.getopt(SAVE_FILES_SPEC, nil, {" \t ", "foo", "\\\n"}, "test") + assert_positional("foo") +end + +function M.test_newlines_embedded_in_tokens() + pg.getopt(CHECK_IMPORT_SPEC, nil, + {"\n-e", "mod.a", "-e", "mod.b\n-e", "mod.c", "-e", "mod.d\n"}, "test") + assert_value("e", "mod.a -e mod.b -e mod.c -e mod.d") +end + +function M.test_trailing_backslash_stripped_after_newline_split() + pg.getopt(WHEEL_SPEC, nil, + {"-C\"key1=val1\" \\\n-C\"key2=val2\" \\\n-C\"key3=val3\"\n"}, "test") + assert_value("C", "\"key1=val1\",\"key2=val2\",\"key3=val3\"") +end + -- Errors