Format last three markdown files; cap billsplit's Android build memory

Brings the repo to prettier-clean across all 525 tracked md/json/yaml files.
The markdown edits are cosmetic: escaping `L*` so it stops rendering as
emphasis, normalising `*em*` to `_em_`, and aligning tables.

billsplit/android/gradle.properties shipped from the Flutter template asking
for `-Xmx8G -XX:MaxMetaspaceSize=4G` — more than this machine has. Lowered to
768m/512m and, more importantly, cut the number of JVMs: Kotlin compiles
in-process, no persistent daemon, no parallel execution, one worker.

Also repairs a regression an earlier prettier pass introduced. That pass
pretty-printed test/fixtures/sample_receipt.json, but
`accepts bare JPK payload and bare JWS token` reads the fixture as raw TEXT
with a regex matching `"data":"..."` with no space after the colon. The
inserted space made the match null and the null-check operator threw.
Comparing the parsed JSON for equality had passed and proved nothing about
that raw-text contract.

The fixture is restored to its original minified bytes — a real e-paragon
arrives as single-line JSON — and test/fixtures/ is now in .prettierignore so
captured payloads stay byte-faithful. The regex also tolerates whitespace now
and asserts the match is non-null with a reason, so the same change would fail
readably instead of crashing. Verified against both the minified and the
pretty-printed fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 13:25:48 +02:00
parent 5b654fc286
commit ed1a224ce7
8 changed files with 120 additions and 802 deletions

View File

@ -15,7 +15,7 @@ description: Use BEFORE deploying any Docker-based self-hosted service on this m
- **The firewall is owned entirely by**
`linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh`. It
regenerates `/etc/nftables.conf` from scratch on every `setup` run (`flush
ruleset` + fixed heredoc). **Never hand-edit `/etc/nftables.conf` directly or
ruleset` + fixed heredoc). **Never hand-edit `/etc/nftables.conf` directly or
from a second script** — the next `setup` re-run (e.g. adding a WireGuard peer)
will silently wipe an independent edit. To open a new port, extend this script
(see its `allow-web` subcommand, which persists an `ALLOW_WEB` flag and adds
@ -48,7 +48,7 @@ inside the container.
**Root cause**: this host's custom nftables ruleset defines its own
`chain forward { policy drop; }` with **zero accept rules**. Docker manages a
*separate* set of forwarding rules via legacy `iptables` (`ip_tables` kernel
_separate_ set of forwarding rules via legacy `iptables` (`ip_tables` kernel
module, not `nf_tables`) that correctly `ACCEPT` bridge-network traffic — but
`ip_tables` and `nf_tables` both register at the same `NF_INET_FORWARD` netfilter
hook, and **a DROP verdict from either one is terminal**, regardless of what the
@ -57,6 +57,7 @@ all Docker bridge-network container egress, even though `docker ps`/`iptables -L
DOCKER-FORWARD` show everything looks correctly configured on Docker's side.
Confirm this is what's happening:
```bash
docker exec <container> wget -qO- --timeout=5 https://api.ipify.org # hangs/times out
sudo nft list ruleset | grep -A3 "chain forward" # policy drop, no rules
@ -73,7 +74,7 @@ with host networking there's no bridge isolation to rely on.
**Alternative (broader fix, requires explicit user sign-off)**: add
`ct state established,related accept` + `ip saddr 172.16.0.0/12 accept` to
`setup_wireguard_ssh.sh`'s forward chain. Fixes egress for *all* Docker
`setup_wireguard_ssh.sh`'s forward chain. Fixes egress for _all_ Docker
containers on the host (useful if `joplin-server`/`open-webui` ever need
outbound access too), but it's a change to shared, security-relevant
infrastructure beyond any single service's scope — ask before applying it,
@ -86,6 +87,7 @@ under `set -o pipefail`: `grep -q` quits at the first match, SIGPIPE-ing
`docker logs` before it finishes writing, and `pipefail` propagates that
upstream non-zero exit even though `grep` itself succeeded. Fix: capture into a
variable first, then grep the variable —
```bash
logs=$(docker logs "$container" 2>&1)
if grep -q "ready-string" <<<"$logs"; then ...
@ -123,12 +125,14 @@ not an implementation detail.
## Boot persistence — confirm these are all `enabled`, not just running
A service surviving a reboot needs all of:
```bash
systemctl is-enabled docker # containers won't come back without this
systemctl is-enabled nftables # firewall rules reload from /etc/nftables.conf
systemctl is-enabled cronie # or crond -- DuckDNS updater needs this
docker inspect <container> --format '{{.HostConfig.RestartPolicy.Name}}' # unless-stopped or always
```
If all of the above hold, no boot-time script or systemd unit is needed for the
service itself — the Docker daemon restarts `unless-stopped` containers on its
own startup, independent of `docker compose` ever being re-invoked.

View File

@ -7,3 +7,11 @@
**/dist/
**/coverage/
node_modules/
# Captured real-world payloads used as test fixtures. These must stay
# byte-faithful to what the device or service actually emits — a real Polish
# e-paragon arrives as minified single-line JSON, and the tests read the file
# as raw text, not only as parsed JSON. Pretty-printing one silently broke
# `accepts bare JPK payload and bare JWS token`, whose regex matches
# `"data":"..."` with no space after the colon.
billsplit/test/fixtures/

View File

@ -3,7 +3,7 @@
Generated against safe-design-rules (anthonyhobday.com/sideprojects/saferules).
Report only — nothing in either app was changed by the audit itself.
Contrast ratios and L* (perceptual lightness) values below were computed with
Contrast ratios and L\* (perceptual lightness) values below were computed with
the WCAG relative-luminance formula against each file's literal hex values —
not measured from a rendered page.
@ -111,7 +111,7 @@ N/A below with spot-checks noted instead.
| 15 | Closer elements lighter | Pass (single-level) | `.card` and `.fileItem` (both `rgba(30,27,75,0.7)`, `ModeSelect.module.css:35-48`, `ScoreScreen.module.css:79-87`) sit lighter than the darkest bg stop — directionally correct, though both reuse the identical translucent value regardless of nesting depth, so distinct elevation levels aren't distinguishable from each other. |
| 16 | Shadow blur ≈ 2x distance | N/A | Zero `box-shadow` declarations found across all `*.module.css` (grep-confirmed). |
| 17 | Simple on complex / complex on simple | Pass | Foreground text/controls sit on a flat gradient (no photographic noise); text on `.card`'s translucent fill is plain-on-plain. |
| 18 | Container brightness limits | N/A (whole-palette) | No shared token system to audit (Rule 4). The one measurable stack — the page gradient itself (L*4.7→L*21.1→L*15.4 across its 3 stops, computed) — is decorative, not the container-on-container relationship this rule targets. |
| 18 | Container brightness limits | N/A (whole-palette) | No shared token system to audit (Rule 4). The one measurable stack — the page gradient itself (L*4.7→L*21.1→L\*15.4 across its 3 stops, computed) — is decorative, not the container-on-container relationship this rule targets. |
| 19 | Outer padding ≥ inner padding | Pass | `.container` `32px 16px` (`ScoreScreen.module.css:8`) > nested `.fileItem` `6px 12px` (`:85`); `.zone` `24px 16px` (`DropZone.module.css:9`) > any nested element's own padding. |
| 20 | Body text ≥16px | **Violation** | `.cardDesc` (`ModeSelect.module.css:64-69`), the descriptive copy under each mode card, is `0.85rem` (13.6px) — under the 16px floor. (Small labels like `.divider`/`.gridLabel` are UI chrome, not flagged.) |
| 21 | Line length ~70 chars | N/A | `.cardDesc` is width-constrained by its 220px `.card` parent (`ModeSelect.module.css:40`) to a few words per line — can't exceed ~70 chars; not a meaningful case to score. |

View File

@ -1,6 +1,24 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
# Memory budget. The Flutter template ships `-Xmx8G -XX:MaxMetaspaceSize=4G`
# here, which asks for more than this 7.6 GB machine has and got a whole
# session OOM-killed. What actually brings the footprint down is cutting the
# NUMBER of JVMs, not just the heap: without these flags Gradle runs a daemon
# plus a separate Kotlin compile daemon plus workers, each with its own heap.
#
# Budget for anything run on this machine is <= 2 GB. Verify with PSS summed
# from /proc/<pid>/smaps_rollup across the build's own processes — system-wide
# "used" includes everything else, and RSS double-counts shared pages.
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError
# Compile Kotlin inside the Gradle process instead of spawning a second daemon.
kotlin.compiler.execution.strategy=in-process
# No persistent daemon, no parallel project execution, one worker.
org.gradle.daemon=false
org.gradle.parallel=false
org.gradle.workers.max=1
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
# This builtInKotlin flag was added by the Flutter template. Kept false to
# match every sibling app (todo, habit_stack, home_inventory, diet_guard_app),
# which all build fine on this same AGP 9.0.1 / Gradle 9.1.0 pair.
android.builtInKotlin=false

View File

@ -63,8 +63,12 @@ void main() {
test('accepts bare JPK payload and bare JWS token', () {
final res = parseEParagonDetailed(fixture);
expect(res.receipt.items, isNotEmpty);
// Raw JWS from the wrapper should parse identically.
final jws = RegExp('"data":"([^"]+)"').firstMatch(fixture)!.group(1)!;
// Raw JWS from the wrapper should parse identically. Tolerate whitespace
// around the colon: the fixture is minified as the device emits it, but a
// pretty-printed payload is equally valid JSON and must not fail here.
final match = RegExp(r'"data"\s*:\s*"([^"]+)"').firstMatch(fixture);
expect(match, isNotNull, reason: 'fixture must carry a "data" JWS field');
final jws = match!.group(1)!;
final fromJws = parseEParagon(jws);
expect(fromJws.items.length, res.receipt.items.length);
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,62 @@
{
"intent": "Bring the last three unformatted markdown files under prettier, cap the billsplit Android build's memory so it cannot exhaust a 7.6 GB machine, and repair an e-paragon parser test that a previous prettier pass silently broke.",
"scope": [
"DESIGN_AUDIT_TODO.md, .github/skills/self-hosted-service-exposure/SKILL.md, linux_configuration/scripts/boot_recovery/README.md — prettier formatting only",
"billsplit/android/gradle.properties — JVM and daemon memory ceilings",
"billsplit/test/fixtures/sample_receipt.json — restored to its original minified bytes",
"billsplit/test/eparagon_parser_test.dart — regex hardened against JSON whitespace",
".prettierignore — captured real-world fixtures excluded",
"Non-goal: making billsplit build an Android APK; that is blocked upstream by file_picker and is not attempted here",
"Non-goal: any change to billsplit's Dart behaviour or the app's dependency set"
],
"changes": [
"Formatted the three remaining unformatted markdown files. The edits are cosmetic and markdown-correct: escaping `L*` to `L\\*` so it stops rendering as emphasis, normalising `*em*` to `_em_`, and aligning tables.",
"Lowered billsplit/android/gradle.properties from the Flutter template's `-Xmx8G -XX:MaxMetaspaceSize=4G` to `768m/512m`, and cut the number of JVMs with kotlin.compiler.execution.strategy=in-process, org.gradle.daemon=false, org.gradle.parallel=false and org.gradle.workers.max=1. The template default asks for more than this machine has.",
"Fixed a regression I introduced and had already pushed: an earlier prettier pass pretty-printed billsplit/test/fixtures/sample_receipt.json, and the test `accepts bare JPK payload and bare JWS token` reads that file as RAW TEXT with a regex matching `\"data\":\"...\"` with no space after the colon. Pretty-printing inserted a space, the match returned null, and the null-check operator threw. My earlier check compared the PARSED JSON for equality, which passed and proved nothing about the raw-text contract.",
"Restored the fixture to its original minified bytes — verified byte-identical to the pre-formatting version — because it is a captured real e-paragon and a real one arrives as single-line JSON.",
"Added billsplit/test/fixtures/ to .prettierignore so captured payloads stay byte-faithful and this cannot recur by the same route.",
"Hardened the test regex to `\\\"data\\\"\\\\s*:\\\\s*\\\"([^\\\"]+)\\\"` and added an explicit isNotNull assertion with a reason, so a whitespace change now fails with a readable message instead of a bare null-check crash."
],
"verification": [
{
"command": "flutter test --no-pub --coverage (billsplit)",
"result": "pass",
"evidence": "59 passed. Before the fix this run failed with 'Null check operator used on a null value' at eparagon_parser_test.dart:67."
},
{
"command": "Generalisation check — temporarily pretty-print the fixture and re-run the repaired test, then restore",
"result": "pass",
"evidence": "The test passes against BOTH the minified fixture and the pretty-printed form that originally broke it, so the fix is not specific to the one byte layout. Fixture confirmed byte-identical to the original afterwards."
},
{
"command": "python3 -m python_pkg.billsplit_coverage",
"result": "pass",
"evidence": "100% line coverage across 11 lib files."
},
{
"command": "pytest over python_pkg/*/tests and linux_configuration/tests, --cov-branch --cov-fail-under=100",
"result": "pass",
"evidence": "1117 passed, 12 skipped, TOTAL 100% branch coverage."
},
{
"command": "prettier --check over all 525 tracked md/json/yaml files",
"result": "pass",
"evidence": "'All matched files use Prettier code style!' The fixture is now reported as excluded rather than checked."
},
{
"command": "pre-commit run --files $(git diff --cached --name-only), inside systemd-run MemoryMax=2G MemorySwapMax=0",
"result": "pass",
"evidence": "Every hook passes with no bypass, inside a hard 2G cgroup."
}
],
"risks": [
"billsplit still cannot build an Android APK: file_picker 11.0.3 (the latest on pub.dev) puts the Kotlin Gradle Plugin on the global classpath via rootProject.allprojects, which AGP 9 rejects, while turning built-in Kotlin off leaves file_picker's own Kotlin uncompiled. The launcher icon is therefore verified only by the preview sheet and a resource-resolution check, never by a build.",
"The gradle.properties ceilings were exercised through a full compileDebugJavaWithJavac but never through a complete successful assembleDebug, because the build fails upstream of packaging for the reason above.",
"Peak PSS observed during those Android builds was 2.03 GiB and 3.01 GiB — above the 2 GB budget for this machine. Nothing was OOM-killed, but an Android build here is not yet inside budget.",
"The .prettierignore entry covers billsplit/test/fixtures/ only. Another app adding captured payloads elsewhere gets no such protection and can hit the identical trap."
],
"rollback": [
"git revert the commit. The markdown edits and .prettierignore entry are inert; reverting gradle.properties restores the 8G template default, which is what endangered the machine, so prefer keeping that hunk.",
"After rollback, re-run flutter test in billsplit: the fixture and the regex must move together, since the old brittle regex only works against the minified fixture."
]
}

View File

@ -24,11 +24,11 @@ sudo ./install.sh
Installs:
| Path | Purpose |
|---|---|
| `/usr/local/sbin/boot-repair` | the repair tool |
| `/etc/pacman.d/hooks/05-boot-mounted-guard.hook` | pre-transaction **gate** |
| `/etc/pacman.d/hooks/99-boot-autorepair.hook` | post-transaction auto-repair |
| Path | Purpose |
| ------------------------------------------------ | ---------------------------- |
| `/usr/local/sbin/boot-repair` | the repair tool |
| `/etc/pacman.d/hooks/05-boot-mounted-guard.hook` | pre-transaction **gate** |
| `/etc/pacman.d/hooks/99-boot-autorepair.hook` | post-transaction auto-repair |
It lives on the **root** filesystem by necessity: the ESP is unreachable by
definition when this is needed.
@ -51,13 +51,13 @@ No network is used at any point. Missing kernel modules are recovered from
### Options
| Flag | Meaning |
|---|---|
| `--dry-run` | Report problems, change nothing |
| `--preflight` | Exit non-zero unless the ESP is mounted (used by the gate hook) |
| `--auto` | Quiet repair for the post-transaction hook |
| `--root DIR` | Operate on another root (fixtures, or an Arch ISO chroot) |
| `--esp DEVICE` | Override ESP auto-detection |
| Flag | Meaning |
| -------------- | --------------------------------------------------------------- |
| `--dry-run` | Report problems, change nothing |
| `--preflight` | Exit non-zero unless the ESP is mounted (used by the gate hook) |
| `--auto` | Quiet repair for the post-transaction hook |
| `--root DIR` | Operate on another root (fixtures, or an Arch ISO chroot) |
| `--esp DEVICE` | Override ESP auto-detection |
Exit status: `0` consistent/repaired, `1` problems remain, `2` usage error.
@ -65,7 +65,7 @@ Exit status: `0` consistent/repaired, `1` problems remain, `2` usage error.
1. Root filesystem read-only after a failed boot → remount rw
2. `noauto`/`nofail` on `/boot` in fstab → reset to `defaults` (backed up).
`nofail` turns a loud emergency drop into a *silent* boot of a stale kernel,
`nofail` turns a loud emergency drop into a _silent_ boot of a stale kernel,
which is strictly worse.
3. Orphaned kernel files written into the unmounted `/boot` → delete
4. `vfat` unloadable → restore `fs/fat` + `fs/nls` from the cached kernel
@ -105,7 +105,7 @@ off the real root filesystem while proving the ESP survives.
- 31/31 fixture tests, 11/11 live tests, `shellcheck` clean
- Gate proven to abort a real kernel transaction with the ESP unmounted:
`error: failed to commit transaction`, *no packages upgraded*, nothing
`error: failed to commit transaction`, _no packages upgraded_, nothing
written into the unmounted `/boot`
## Limitation