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 2acda15168)
This commit is contained in:
Miro Hrončok 2026-05-07 21:06:51 +02:00
parent fe782c8528
commit b2145641c0
3 changed files with 76 additions and 15 deletions

View File

@ -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 <mhroncok@redhat.com> - 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 <mhroncok@redhat.com> - 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

View File

@ -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

View File

@ -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