Files
RedBear-OS/local/scripts/check-recipe-escapes.py
T
vasilito f25ccf07c6 icu: assemble the data blob with --noexecstack; gate continuation comments
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.
2026-08-04 20:58:50 +03:00

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())