Commit47cdf72shipped a pattern that matched one closing paren and emitted one, netting an extra `)` in the Angular bundle: c(2,"filterByProviders",["STORJ_IX","S3","B2"]))("required",!0) ^^ syntax error The TrueNAS web UI went blank. Worse, MARKER was now present in the file, so every later run reported "already patched" and skipped -- the patch could not heal itself, and the bundle had to be hand-restored from the .pre-truecloud-patch backup. patch_ui.py now compares the parenthesis balance before and after substitution and refuses to write if it changed. A bundle we cannot patch correctly is left exactly as it was: an unpatched UI is a missing dropdown entry, a corrupted one is a dead web UI. Tests cover the real regression (verbatim47cdf72pattern) end to end: it still matches, the balance still shifts, main() refuses, and the file on disk is byte-for-byte unchanged. README documents the manual recovery for anyone who already hit it. 93 tests, ruff and shellcheck clean.
172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
"""Tests for the Angular bundle patch.
|
|
|
|
This is the one part of the patch that edits *minified third-party JavaScript* by
|
|
regex, so it is the easiest place to silently produce a broken bundle: a pattern
|
|
that matches nothing leaves the dropdown Storj-only, and a pattern that matches
|
|
sloppily can unbalance the parentheses and take the whole web UI down.
|
|
|
|
Nothing checked it until now. The snippets below are verbatim from a real
|
|
TrueNAS 25.x bundle (chunk-*.js, pre-patch).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "patch"))
|
|
|
|
from patch_ui import MARKER, _PATTERNS, _match_pattern # noqa: E402
|
|
|
|
# Verbatim from /usr/share/truenas/webui/chunk-FX2QXNQU.js on TrueNAS 25.10.
|
|
# Angular emits the binding as a chained ɵɵproperty(...)(...) call, so the
|
|
# pureFunction call is followed by TWO closing parens: one for pe(...), one for
|
|
# property(...).
|
|
REAL_25X = (
|
|
'c(2,"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj))'
|
|
'("required",!0),r(3'
|
|
)
|
|
|
|
# TrueNAS 24.x and earlier emitted a literal array.
|
|
REAL_24X = 'c(2,"filterByProviders",["STORJ_IX"])("required",!0),r(3'
|
|
|
|
|
|
def apply_patch(content):
|
|
"""Run the same match-and-substitute main() does."""
|
|
find, replace = _match_pattern(content)
|
|
assert find is not None, "no pattern matched"
|
|
patched, count = find.subn(replace, content)
|
|
return patched, count
|
|
|
|
|
|
def paren_delta(s):
|
|
"""Net paren balance. The snippets are fragments of a minified file, so they
|
|
are not balanced on their own -- what must hold is that patching does not
|
|
CHANGE the balance. Consuming one paren too many is a syntax error in the
|
|
bundle, and the whole TrueNAS web UI goes blank."""
|
|
return s.count("(") - s.count(")")
|
|
|
|
|
|
@pytest.mark.parametrize("source", [REAL_25X, REAL_24X], ids=["25.x", "24.x"])
|
|
class TestAgainstRealBundles:
|
|
def test_matches_exactly_once(self, source):
|
|
# main() refuses to write unless count == 1 — more than one match would
|
|
# mean the pattern is too loose to trust against a minified bundle.
|
|
_patched, count = apply_patch(source)
|
|
assert count == 1
|
|
|
|
def test_result_contains_all_three_providers(self, source):
|
|
patched, _ = apply_patch(source)
|
|
assert MARKER in patched
|
|
assert '"filterByProviders",["STORJ_IX","S3","B2"]' in patched
|
|
|
|
def test_patch_does_not_change_paren_balance(self, source):
|
|
# Consuming one paren too many (or too few) is a syntax error in the
|
|
# bundle and the entire TrueNAS web UI goes blank. This is the invariant
|
|
# the 25.x pattern has to get right: it eats `pe(...)` which sits inside
|
|
# a chained property(...)(...) call.
|
|
patched, _ = apply_patch(source)
|
|
assert paren_delta(patched) == paren_delta(source)
|
|
|
|
def test_surrounding_code_is_untouched(self, source):
|
|
patched, _ = apply_patch(source)
|
|
assert patched.startswith("c(2,")
|
|
assert patched.endswith('("required",!0),r(3')
|
|
|
|
def test_patch_is_idempotent(self, source):
|
|
# apply.sh re-runs every boot; MARKER short-circuits an already-patched
|
|
# file, but the pattern must also not match its own output.
|
|
patched, _ = apply_patch(source)
|
|
find, _replace = _match_pattern(patched)
|
|
if find is not None:
|
|
# Only the 24.x literal-array pattern may still "match" — and only if
|
|
# it would produce the same text. Anything else means double-patching.
|
|
again, _ = apply_patch(patched)
|
|
assert again == patched, "re-patching must be a no-op"
|
|
|
|
|
|
def test_storj_only_bundle_is_recognised():
|
|
assert _match_pattern(REAL_25X)[0] is not None
|
|
|
|
|
|
def test_unrelated_javascript_is_never_touched():
|
|
# A pattern loose enough to hit unrelated code would corrupt the bundle.
|
|
for noise in (
|
|
'c(2,"filterByProviders",pe(115,Rn,i.SomethingElse.Storj))',
|
|
'c(2,"otherBinding",pe(115,Rn,i.CloudSyncProviderName.Storj))',
|
|
'"filterByProviders"',
|
|
):
|
|
find, _ = _match_pattern(noise)
|
|
assert find is None, f"pattern must not match: {noise}"
|
|
|
|
|
|
def test_every_pattern_is_anchored_to_filterbyproviders():
|
|
# Guards against a future pattern broad enough to rewrite arbitrary JS.
|
|
for find, _replace in _PATTERNS:
|
|
assert "filterByProviders" in find.pattern
|
|
|
|
|
|
def test_patterns_compile_and_replacements_reference_group_one():
|
|
for find, replace in _PATTERNS:
|
|
assert isinstance(find, re.Pattern)
|
|
assert r"\1" in replace, "replacement must preserve the binding name"
|
|
|
|
|
|
class TestCorruptionGuard:
|
|
"""A bad pattern must never reach the bundle.
|
|
|
|
This is not hypothetical. Commit 47cdf72 shipped a pattern that consumed one
|
|
closing paren and emitted one, netting an extra `)`:
|
|
|
|
c(2,"filterByProviders",["STORJ_IX","S3","B2"]))("required",!0)
|
|
^^ syntax error
|
|
|
|
The web UI went blank. And because MARKER was then present in the file, every
|
|
subsequent run reported "already patched" and skipped — so the patch could not
|
|
heal itself, and the bundle had to be hand-restored from the backup.
|
|
"""
|
|
|
|
# Verbatim from 47cdf72.
|
|
BROKEN = (
|
|
re.compile(r'("filterByProviders",)\w+\(\d+,\w+,\w+\.CloudSyncProviderName\.Storj\)'),
|
|
r'\1["STORJ_IX","S3","B2"])',
|
|
)
|
|
|
|
def test_the_regression_that_blanked_the_ui_is_detectable(self):
|
|
find, replace = self.BROKEN
|
|
patched, count = find.subn(replace, REAL_25X)
|
|
assert count == 1, "it did match — that is why it got written"
|
|
assert paren_delta(patched) != paren_delta(REAL_25X), (
|
|
"the paren balance changes; this is the signal main() now refuses on"
|
|
)
|
|
|
|
def test_main_refuses_to_write_an_unbalanced_bundle(self, monkeypatch, tmp_path, capsys):
|
|
import patch_ui
|
|
|
|
bundle = tmp_path / "chunk-TEST.js"
|
|
bundle.write_text(REAL_25X, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(patch_ui, "WEBUI_CANDIDATES", [str(tmp_path)])
|
|
monkeypatch.setattr(patch_ui, "_PATTERNS", [self.BROKEN])
|
|
|
|
patch_ui.main()
|
|
|
|
out = capsys.readouterr().out
|
|
assert "refusing to write" in out
|
|
# The bundle must be byte-for-byte untouched — a broken UI is far worse
|
|
# than an unpatched one.
|
|
assert bundle.read_text(encoding="utf-8") == REAL_25X
|
|
|
|
def test_a_good_pattern_still_writes(self, monkeypatch, tmp_path):
|
|
import patch_ui
|
|
|
|
bundle = tmp_path / "chunk-TEST.js"
|
|
bundle.write_text(REAL_25X, encoding="utf-8")
|
|
monkeypatch.setattr(patch_ui, "WEBUI_CANDIDATES", [str(tmp_path)])
|
|
|
|
patch_ui.main()
|
|
|
|
assert MARKER in bundle.read_text(encoding="utf-8")
|
|
assert (tmp_path / "chunk-TEST.js.pre-truecloud-patch").exists()
|