diff --git a/README.md b/README.md index 5b54101..9ec2111 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ mount | grep truecloud-nested # expect no output | Backup fails: `dataset '…' has no snapshot '…'; refusing to back up an incomplete tree` | Working as designed — a descendant dataset was not covered by the snapshot. The backup is refused rather than silently omitting that data. | | Backup fails: `snapshot '…' cannot be read (Permission denied)` | The snapshot exists but is unreadable. Middleware runs as root, so this indicates a real permissions problem, not a missing snapshot. | | `cloud_backup-*` snapshots accumulating | The sweep is not running. Check `apply.log` for the nested patch applying, and confirm `sync.py` carries the `TRUECLOUD_PATCH` block. | +| Web UI blank after a patch | A bad pattern unbalanced the bundle. `apply.sh` now refuses to write in that case, but if you hit it on an older version: restore `chunk-*.js.pre-truecloud-patch` over the live chunk, then re-run `install.sh`. (`MARKER` makes an already-patched file skip, so the patch cannot heal a corrupted bundle by itself.) | | Stale mounts under `/run/truecloud-nested` | A crashed run. The next backup tears them down. To clear them now: `python3 patch/truecloud_nested.py cleanup` (also run by `uninstall.sh` and `recover.sh`). It names any ZFS snapshot an interrupted run left pinned. | ## Supported providers after patching diff --git a/patch/patch_ui.py b/patch/patch_ui.py index 46117df..78c8cce 100644 --- a/patch/patch_ui.py +++ b/patch/patch_ui.py @@ -65,6 +65,11 @@ def _match_pattern(content): return None, None +def _paren_delta(s): + """Net parenthesis balance. Patching must not change it — see main().""" + return s.count("(") - s.count(")") + + def find_bundle(): """ Search WEBUI_CANDIDATES for the JS chunk containing the filterByProviders @@ -135,6 +140,24 @@ def main(): ) return + # Never write JS whose parentheses we have unbalanced. A pattern that eats one + # paren too many is a syntax error in the bundle and the entire TrueNAS web UI + # goes blank -- and because MARKER is then present, every later run reports + # "already patched" and skips, so the patch cannot heal itself. Recovery means + # hand-restoring the .pre-truecloud-patch backup. + # + # This is not hypothetical: it shipped once. Refuse instead. + if _paren_delta(patched) != _paren_delta(content): + print( + "[truecloud-patch] ERROR: the replacement would unbalance the bundle's " + "parentheses — refusing to write.\n" + "[truecloud-patch] The UI is UNCHANGED and still works. This means the " + "pattern no longer fits this TrueNAS build.\n" + "[truecloud-patch] File an issue at " + "https://github.com/sudolulo/truenas-truecloud-patch" + ) + return + tmp = path + ".tmp" try: with open(tmp, "w", encoding="utf-8") as fh: diff --git a/tests/test_patch_ui.py b/tests/test_patch_ui.py index 950b28e..c8ef4f5 100644 --- a/tests/test_patch_ui.py +++ b/tests/test_patch_ui.py @@ -111,3 +111,61 @@ 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()