Skip to content

Zimi 1.9.1 - #62

Closed
epheterson wants to merge 9 commits into
mainfrom
v1.9.1
Closed

Zimi 1.9.1#62
epheterson wants to merge 9 commits into
mainfrom
v1.9.1

Conversation

@epheterson

@epheterson epheterson commented Sep 6, 2026

Copy link
Copy Markdown
Owner

The first day of 1.9.0, in four fixes.

Security

GHSA-5mw2-53vv-9pw6, reopened by a deployment shape. The bootstrap window waives the setup key for the machine running Zimi, and asked the resolved client address whether it was that machine. Behind a reverse proxy on the same host — the standard NAS deployment — _client_ip correctly refuses the forwarded address as a trusted-tier claim and falls back to the hop, which is loopback. Every remote client through that proxy read as the host and skipped the key. Reproduced (200 password set), fixed, now 403 needs_setup_key.

Asked of the socket now, and a forwarded request is never the host whatever the socket says.

Why no test caught it: test_bootstrap_takeover.py replaced _client_ip wholesale, so nothing had ever executed the function that makes the decision. The advisory's PoC was covered; the proxy path never was. The doubles move the socket too now.

Not closed, and it cannot be while loopback is the proof: a same-host forwarder that sends no header at all (socat, proxy_pass with no proxy_set_header) still presents a bare loopback peer. Every mainstream proxy sets a forwarded header. Closing the rest means retiring loopback-as-proof and asking even the host for the setup key, which is a product decision rather than a fix.

Added

lan_admin (#59). The advisory closed a default that deserved closing, and removed with nothing in its place a way people genuinely run Zimi: one household, one LAN, no password. Back as something an operator types (lan_admin: true / ZIMI_LAN_ADMIN=1), off by default, and only while no password is set.

It means a direct private peer. Self-review caught that leaving it at _is_private_client would, behind that same proxy, have handed the admin of a passwordless server to anyone on the internet — the same root cause one layer up.

Fixed

The moon was upside down for half of every month (#60). The sprite shades from a Sun vector already flipped for a waning moon, and the bright-limb angle carries that same flip, so waning moons were turned a further 180 degrees: lit limb on the wrong side, maria inverted. Waxing was correct, which is why it read as random.

Moon on the meridian at each quarter, where the answer is known:

before after
first quarter (waxing) +11.5° +11.5°
last quarter (waning) −172.6° +7.4°

The existing test compared the four renderers to each other. They agreed, on the wrong number. The new one checks against the sky.

zimi import --setup could set up a sidecar the server never reads (#61). It resolves its own data dir from the shell it runs in, so without the service's config it installs into another library's state directory, reports success, and leaves the alive engine greyed out. The Create page now names this server's own directory in the command it offers, shell-quoted with single quotes so a path containing $ survives the paste.

Verification

  • 2,636 passed, 19 skipped; standalone JS suite green
  • Each fix reproduced first, and each test fails without its fix
  • Config file → env → gate checked end to end; bad values rejected; default secure
  • The sidecar path is 403 to unauthorized clients

🤖 Generated with Claude Code

epheterson and others added 9 commits September 5, 2026 21:41
1.9.0 was tagged twice and Desktop Release failed both times. The second
tag had a gate added specifically to prevent that, and it did not, because
it was an approximation: one job on ubuntu-latest, standing in for a four
runner matrix.

Three failures it could not see:

  * Linux. The release's Linux leg is pinned to ubuntu-22.04 for the snap
    base (issue #55). Jammy calls the package libgirepository1.0-dev; the
    workflow still asked for the 24.04 name, so apt exited 100 and no test
    ran. Broken since the pin moved. Both workflows now ask for either.

  * Windows and macOS. Their suites have not run since 1.8.2, because
    Desktop Release fires only on a tag. 296 commits landed over them.

  * macOS Intel specifically. test_a_job_that_keeps_reporting_is_left_alone
    proves a negative — the watchdog leaves a talking job alone — with five
    ticks of margin between a 0.02s heartbeat and a 0.1s stall window. One
    late sleep on a loaded Intel runner and the watchdog was right. The
    heartbeat stays; the window goes to 2s, a hundredfold margin, same 0.4s
    test.

The matrix here is now the release's matrix, and test_ci_contract.py fails
if the two lists ever drift apart again. -v rather than -q because the
Windows leg died at 30% of the suite with no failure summary at all: names
have to stream before the test that crashes the interpreter runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
…not build on jammy

Two more dominoes behind the ones the matrix already found.

os.kill(os.getpid(), SIGINT) does not deliver a signal on Windows. It calls
TerminateProcess with the number as an exit code, so the crawler's interrupt
test terminated pytest itself at 32% of the suite: no summary, no traceback,
just exit 1. signal.raise_signal runs the handler the test is actually about
and does it on every platform.

PyGObject 3.52 requires girepository-2.0, which 22.04 does not ship under any
package name. The Linux leg is pinned to 22.04 for the snap base, so an
unpinned install resolves to a version that cannot build there.

--tb=short so the eighteen Windows failures behind the crash come back with
reasons and not only names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
The Windows suite has never run. Not since 1.8.2 — never: until this
release made CI run the whole suite, the release workflow ran
`python tests/test_unit.py`, which imports the file and executes none of
its tests, plus one file by name. Every Windows build ever shipped was
gated by a step that could not fail. First real run: 56 failed, 2521
passed. Six were the product.

  * `zimi serve` died at boot. The first-run banner — the setup key box
    from this release's own GHSA-5mw2-53vv-9pw6 fix — is drawn in box
    characters, and Windows encodes redirected stdout as cp1252, which has
    none of them. The very first run, the one where no password is set
    yet, crashed before READY. Stdio is resilient now and the banner
    degrades to ASCII rather than raising. PYTHONIOENCODING reproduces it
    on any platform, so the test fails everywhere if this regresses.

  * Deleting a ZIM answered 500 every time: the route unlinked the file
    and released the pooled libzim Archive after. Windows will not remove
    an open file. Release first, which POSIX does not notice.

  * Auto-update orphaned every superseded edition, same cause, inside an
    `except OSError: pass` — so it failed silently and permanently. The
    swallow is now a warning.

  * The create watchdog could never kill a stalled browser: signal.SIGKILL
    does not exist on Windows and naming it raises.

  * Every ZIM built on Windows left four libzim index scratch files beside
    it. libzim unlinks them as it closes, which needs an OS that permits
    unlinking an open file.

  * Mimetypes came from HKEY_CLASSES_ROOT, so a ZIM built on Windows could
    carry entry types no other machine would produce. All five sites now
    share one registry-free guesser.

The rest were tests. Where one assumed something the platform does not
grant — POSIX mode bits, a chmod'd read-only directory — it now asserts
the premise and skips when the OS declines, which also fixes those tests
under root on Linux, where chmod is equally ignored.

Local suite: 2633 passed, 19 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
These are fixes against 1.8.2 and every release before it, not against
unreleased work on this branch, so they belong in Fixed. The cp1252 boot
crash deliberately is not listed: the banner it choked on is new in this
release and never shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
  * The kill-ladder test named signal.SIGKILL itself, so guarding the
    product alone was not enough.
  * Three more mock.patch.object(os, "getloadavg") sites needed create=True;
    I had fixed two of five.
  * The unregister tests removed a ZIM with a bare os.remove, which is not
    the sequence any caller uses now: the handles come out first, and on
    Windows that is not a preference.
  * The read-only write-path fixture had a third chmod site that asserted
    nothing, so on a platform that ignores the mode it built a writable
    directory and then failed the product for not falling back.
  * The index scratch sweep took three of four files and left the fulltext
    index, still mapped. Collect, then wait it out in bounded steps. The
    waits only happen when a file is genuinely still held, so POSIX never
    sleeps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
…a directory

os.WNOHANG does not exist on Windows, and the AttributeError from naming
it was not caught, so _process_alive raised and the watchdog could never
establish that anything had died. What sat underneath was worse: os.kill
on Windows sends no signal, it calls TerminateProcess with the number
given, so the liveness probe os.kill(pid, 0) would have killed the process
it was asking about. OpenProcess + GetExitCodeProcess is the real question.

The index scratch survived three rounds of sweeping because a Xapian
database is a DIRECTORY and os.remove cannot delete one. I narrowed two
'leaves nothing behind' assertions to accommodate that before finding it;
both are reverted, because the guarantee was right and the sweep was
wrong. The sweep test now covers the directory case.

Unrelated, and found by the same run: the vocab-cache signature test
asserted that one inserted row moves a SQLite file's size or mtime. On a
filesystem with coarse timestamp granularity it moves neither, which is a
flake rather than a signal. It writes enough rows to be observable now.

Local suite: 2633 passed, 19 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
A security fix, a setting 1.9.0 should not have removed, and two bugs a
single reporter found in a day.

GHSA-5mw2-53vv-9pw6, reopened by a deployment shape. The bootstrap window
waives the setup key for the machine running Zimi, and asked the RESOLVED
client address whether it was that machine. Behind a reverse proxy on the
same host — the standard NAS deployment — _client_ip correctly refuses the
forwarded address as a trusted-tier claim and falls back to the hop, which
is loopback, so every remote client through that proxy read as the host and
skipped the key. It is asked of the socket now, and a forwarded request is
never the host whatever the socket says.

The bootstrap tests replaced _client_ip wholesale, so nothing had ever run
the function that makes that decision: the advisory's own PoC was covered
and the proxy path was not. The doubles move the socket too now.

lan_admin (#59). The advisory closed a default that deserved closing and
removed, with nothing in its place, a way people genuinely run Zimi: one
household, one LAN, no password. It is back as something an operator types,
and it means a DIRECT private peer — self-review caught that leaving it at
_is_private_client would, behind that same proxy, have handed the admin of
a passwordless server to anyone on the internet.

The moon was upside down for half of every month (#60). The sprite shades
from a Sun vector already flipped for a waning moon, and the bright-limb
angle carries the same flip, so waning moons were turned a further 180
degrees: lit limb on the wrong side, maria inverted. The four renderers all
agreed, and all agreed on the wrong number, which is precisely what a
consistency test cannot see. There is now one that checks the sky.

zimi import --setup could set up a sidecar the server never reads (#61). It
resolves its own data dir from the shell it runs in; run without the
service's config it installs into another library's state directory, reports
success, and leaves the engine greyed out. The Create page now names this
server's directory in the command it offers.

2636 passed, 19 skipped, plus the standalone JS suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
main carries the squash of #58, which this branch already holds as its own
commits, plus the v1.9.0 appcast update. Keep this tree and take main's
appcasts, which are the only content on main that is not already here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
macos-latest failed test_the_line_buffer_is_bounded on the same commit that
passed it in the run beside it: "creation job never finished".

_wait_done polled 400 times at 10ms, which reads as a four-second ceiling
and is not one. Each turn also makes an HTTP request, so what the count
actually measures is how fast the runner is that minute. The 550-line buffer
test is the slowest of them, so it is the one that ran out of turns first.

Both helpers take a wall-clock deadline now, 30 seconds. A passing test
still returns the moment the job is done and the suite does not get slower;
the only thing that waits is a job that genuinely hung, and reporting that
honestly is worth the wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y97WKqARyZKB3uDfALncC8
@epheterson epheterson mentioned this pull request Sep 6, 2026
@epheterson

Copy link
Copy Markdown
Owner Author

Superseded by #63: same tree, commit history without session links.

@epheterson epheterson closed this Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant