f25ccf07c6
ICU emits its data as generated assembly (icudt75l_dat.S). Without
-Wa,--noexecstack the assembler produces an object with no .note.GNU-stack
section, and modern binutils warns:
ld: warning: icudt75l_dat.o: missing .note.GNU-stack section implies
executable stack
Harmless alone -- except KDE's ECM links with -Wl,--fatal-warnings, so every
KDE consumer of static ICU fails outright (first hit: plasma-workspace
applets/digital-clock, collect2: error: ld returned 1). Fixed at the source
rather than suppressed downstream, which would have to be repeated per consumer.
Also gates the '#'-inside-a-backslash-continuation trap, which terminates the
continuation and silently drops every remaining argument. It has now bitten
three times, most recently while writing THIS commit: the noexecstack rationale
was first placed between two continued configure flags, which would have
dropped the rest of ICU's configure line. Moved above the invocation.
The check scans the RAW file, not the parsed TOML. In a multi-line basic string
a trailing backslash is itself a TOML line-continuation escape, so the newline
is gone before the value is handed over and the parsed string has no trailing
backslashes at all. The first version scanned the parsed value and silently
found nothing -- caught by a self-test, not by review, which is the same class
of invisible failure the gate exists to prevent.
154 lines
6.0 KiB
Python
Executable File
154 lines
6.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Catch TOML escape sequences that silently corrupt recipe shell scripts.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
Recipe build scripts live in TOML multi-line basic strings:
|
|
|
|
script = \"\"\"
|
|
sed -i 's/\\bFoo\\b//g' file
|
|
\"\"\"
|
|
|
|
In that context a SINGLE backslash is a TOML escape, not a literal backslash.
|
|
TOML defines \\b (backspace, U+0008), \\f, \\r, \\t, \\n and a few others. So a
|
|
regex written as \\bFoo\\b parses to <BS>Foo<BS> -- a pattern that matches
|
|
nothing. There is NO TOML error and NO sed error: the command runs, exits 0,
|
|
and quietly does nothing.
|
|
|
|
This bit the tree three times before being caught:
|
|
* `a\\` in a sed append needing `a\\\\`
|
|
* `netwm\\.h` needing `netwm[.]h`
|
|
* `\\bX11::X11\\b` in plasma-workspace, which silently stripped nothing and
|
|
produced four "Target links to X11::X11 but the target was not found"
|
|
CMake errors -- and had appeared to work only because earlier runs had
|
|
already mutated the tracked source tree.
|
|
|
|
The failure mode is the dangerous kind: invisible, and it masquerades as a
|
|
working sed. Hence a mechanical gate rather than reviewer vigilance.
|
|
|
|
FIXES
|
|
* word boundary -> double it: \\\\b
|
|
* literal dot -> bracket it: [.] (needs no backslash at all)
|
|
* anything else -> double the backslash
|
|
|
|
Exit 0 clean, 1 if any injection found.
|
|
"""
|
|
import pathlib
|
|
import sys
|
|
import tomllib
|
|
|
|
# Control characters TOML can produce from a single-backslash escape. \n and \t
|
|
# are excluded: they are legitimate and intentional in shell scripts.
|
|
SUSPECT = {
|
|
"\x08": r"\b (backspace) -- almost always a regex word boundary; use \\b",
|
|
"\x0c": r"\f (form feed) -- use \\f if literal",
|
|
"\x0b": r"\v (vertical tab) -- use \\v if literal",
|
|
"\x07": r"\a (bell) -- in sed, 'a\' append needs \\",
|
|
"\r": r"\r (carriage return) -- use \\r if literal",
|
|
"\x1b": r"\e (escape) -- use \\e if literal",
|
|
}
|
|
|
|
# recipes/wip/ is a staging area of unfinished ports that are in no config and
|
|
# are not expected to parse; scanning it only produces noise. Everything else is
|
|
# fair game, including recipes not currently in a config -- a recipe that cannot
|
|
# be parsed is broken whether or not anything builds it today.
|
|
SKIP_PARTS = ("/target/", "/stage/", "/source/", "/.git/", "recipes/wip/")
|
|
|
|
|
|
def walk(node, path, findings):
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
walk(value, f"{path}.{key}", findings)
|
|
elif isinstance(node, list):
|
|
for i, value in enumerate(node):
|
|
walk(value, f"{path}[{i}]", findings)
|
|
elif isinstance(node, str):
|
|
for lineno, line in enumerate(node.splitlines(), 1):
|
|
for char, advice in SUSPECT.items():
|
|
if char in line:
|
|
shown = line.replace(char, f"<<{advice.split()[0]}>>").strip()
|
|
findings.append((path, lineno, advice, shown[:100]))
|
|
|
|
|
|
def find_continuation_comments(raw_text):
|
|
r"""Comment lines inside a backslash-continued shell command.
|
|
|
|
A '#' line terminates a `\`-continuation, so EVERY remaining argument is
|
|
silently dropped -- no shell error, no build error, just a command that
|
|
quietly ran with fewer flags than it appears to. In kwin this dropped
|
|
KWIN_BUILD_SCREENLOCKER, TABBOX, GLOBALSHORTCUTS and RUNNERS, so cmake took
|
|
its own defaults and configure later aborted on a REQUIRED package the
|
|
recipe had explicitly disabled.
|
|
|
|
Scans the RAW FILE, not the parsed TOML value. In a TOML multi-line basic
|
|
string a trailing backslash is itself a line-continuation escape: TOML
|
|
removes the newline before the value is ever handed over, so the parsed
|
|
string contains no trailing backslashes and this can only be detected in
|
|
the source text. (My first version scanned the parsed value and silently
|
|
found nothing -- caught by a self-test, not by review.)
|
|
"""
|
|
findings = []
|
|
prev_cont = False
|
|
for lineno, line in enumerate(raw_text.splitlines(), 1):
|
|
stripped = line.strip()
|
|
if prev_cont and stripped.startswith("#"):
|
|
findings.append((lineno, stripped[:88]))
|
|
# A line ending in an ODD number of backslashes continues.
|
|
trailing = len(line.rstrip()) - len(line.rstrip().rstrip("\\"))
|
|
prev_cont = (trailing % 2) == 1
|
|
return findings
|
|
|
|
|
|
def main() -> int:
|
|
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")
|
|
findings_total = 0
|
|
scanned = 0
|
|
unparsable = []
|
|
|
|
for recipe in sorted(root.rglob("recipe.toml")):
|
|
as_str = str(recipe)
|
|
if any(part in as_str for part in SKIP_PARTS) or as_str.startswith("build/"):
|
|
continue
|
|
scanned += 1
|
|
try:
|
|
with open(recipe, "rb") as handle:
|
|
data = tomllib.load(handle)
|
|
except Exception as exc: # a broken recipe is its own, louder problem
|
|
unparsable.append((recipe, exc))
|
|
continue
|
|
|
|
findings = []
|
|
walk(data, "", findings)
|
|
|
|
for lineno, shown in find_continuation_comments(recipe.read_text(errors="replace")):
|
|
findings_total += 1
|
|
print(f"{recipe}:{lineno}")
|
|
print(" comment inside a backslash-continued command -- every")
|
|
print(" remaining argument on that command is silently dropped")
|
|
print(f" {shown}")
|
|
for path, lineno, advice, shown in findings:
|
|
findings_total += 1
|
|
print(f"{recipe}{path} (line {lineno} of the string)")
|
|
print(f" injected: {advice}")
|
|
print(f" {shown}")
|
|
|
|
for recipe, exc in unparsable:
|
|
print(f"{recipe}: TOML PARSE ERROR: {exc}")
|
|
|
|
if unparsable:
|
|
print(f"\n{len(unparsable)} recipe(s) failed to parse.")
|
|
if findings_total:
|
|
print(
|
|
f"\n{findings_total} control-character injection(s) in {scanned} recipes.\n"
|
|
"These make shell commands silently no-op. See this script's docstring."
|
|
)
|
|
return 1
|
|
if unparsable:
|
|
return 1
|
|
print(f"recipe escape check: {scanned} recipes clean")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|