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
This commit is contained in:
parent
ef1bdc962d
commit
2acda15168
@ -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
|
||||
@ -179,6 +179,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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user