Skip to content
zit Timeline
Login

Timeline

Parents and children of check-in [cec4afaf89]

2026-07-19
01:54
Add merge3_properties: algebraic-property fuzzing over the three-way merge

Silent merge corruption is the worst failure a version-control system can have — it produces a tree that looks right — and merge3 had no fuzz target. This adds one, driving the merger with generated base/current/other triples and asserting the properties that must hold for any input.

Oracle 4 (no invented lines) needs a carve-out, because the merger is supposed to fabricate lines inside a conflict envelope. That carve-out is only sound if the recorded spans really are envelopes, so expectNoInventedLines validates the claim before subtracting anything: each span must be in-bounds, open at a line boundary with conflict_sentinel, close with conflict_terminator, start at or after the previous span's end, and carry no second terminator inside it.

The ordering and interior-terminator checks are load-bearing, not belt-and- braces. inAnySpan matches on overlap, so an over-long span exempts every output line up to its end. End-pinning alone admits a single span stretched from the first envelope's start to the last one's end — it opens and closes correctly and overlaps nothing — which would exempt the clean lines between two envelopes from the oracle entirely. closes_once catches that case on the first envelope's terminator. merge3 asserts ordering itself, but only under runtime_safety, which a ReleaseFast sweep — the build a fuzz run uses — erases; this loop holds in every optimize mode.

merge3.zig gains one export: conflict_terminator, the closing counterpart of the existing conflict_sentinel, with a comptime tripwire pinning its shape. Additive only, no behavior change — it lets the oracle pin both envelope ends against the module that produces them instead of re-typing the literal, so the two cannot drift apart.

Battery green in the worktree: zig fmt --check ., zig build, zig build test, zig build e2e, zig build dst (6 seeds x 1000 steps). The new target runs clean at seed 1 over 2000 rounds; no bug was found, so no regression seed is pinned. closed check-in: 21ca364fb3 user: dmestas tags: release-0.1

00:17
Merge release-0.1: Add transport_frame_garbage and serve_request_garbage seeded fuzz targets

Lands P4-F6, closing the last unfuzzed attack surface in the release scope. transport.zig and serve.zig are the only two files a remote peer can reach with bytes it fully controls, and neither had a fuzz target: the sync_parsers_adversarial target covers the layer above them, so the HTTP framing itself — the first code to touch untrusted input — was exercised only by hand-written smoke cases. These two targets hit that framing directly.

The increment: src/transport_fuzz.zig (205 L) and src/serve_fuzz.zig (303 L), registered in the fuzz_tests.zig dispatch table alongside the existing 24 and pinned by that file's registration test. serve.zig's driveHttp is promoted to pub with a comment stating why — it is the receiveHead to handleHttpRequest seam the smoke tests and the fuzz target both need, and duplicating it would have meant fuzzing a copy of the request path rather than the real one. Both sides stay in-memory, so neither target binds a port and neither can flake in CI. dst.zig picks up the two targets plus fuzz_tests.zig itself, the latter so the registration pin has a test step to run in: zig build fuzz has none.

Evidence, re-verified on the merged tree rather than on the branch, since trunk had advanced: zig fmt --check ., zig build, and zig build test each exit 0. Both new targets run clean seeded — invoking zig build fuzz with each target name and --seed 1 exits 0 for transport_frame_garbage and for serve_request_garbage — and found no crash, leak, or UB to fix, so this step carries no pinned-seed regression. The merge reported no conflicts; a repo-wide grep for conflict markers is clean, and fossil extras is empty, so both added files are tracked and nothing referenced went unadded. check-in: b8db5ecabd user: dmestas tags: trunk

00:10
Add transport_frame_garbage and serve_request_garbage seeded fuzz targets

The 24 existing fuzz targets cover the delta, artifact, sync-parser and logstore layers, but not the two CLI-layer modules that consume bytes an untrusted remote peer chose. `sync_parsers_adversarial` covers the layer above; the HTTP framing itself was unfuzzed. These are the last two wire surfaces on the release bar, and they are what a hostile peer reaches first.

What landed:

- `src/transport_fuzz.zig` — `frameGarbage`, registered as `transport_frame_garbage`. Client-side §4/§4.1 framing and redirect policy: the round-trip law (whatever `frame` produces, `unframe` returns exactly those plaintext bytes), mutation of framed containers (bit damage never survives as *different* plaintext — it either fails the container's Adler-32/zlib checks or, in the length-field range the §4.1 compression-length erratum makes a capacity bound, decodes back to the same bytes), and garbage `Location` headers through `resolveRedirect`. Totality is the asserted contract: every input yields a value whose postconditions hold or a declared member of `TransportError`.

- `src/serve_fuzz.zig` — `requestGarbage`, registered as `serve_request_garbage`. Server-side HTTP request path: raw bytes into `std.http.Server.receiveHead`, then `handleHttpRequest`'s dispatch (`classifyXfer` -> body read -> responder -> reply framing). It does not re-feed `handleXfer` in isolation — that decoding is already fuzzed twice — it reaches it only as the tail of a full-path dispatch, which is the point. Asserted contract: any `receiveHead` error is a clean rejection, `handleHttpRequest` returns void, and any produced response begins with an HTTP/1.1 status line.

- `src/serve.zig`: `driveHttp` becomes `pub`. It is the request-path seam two callers need and neither should reimplement — the §4 smoke tests and this fuzz target. In-memory reader/writer on both sides, so the target binds no port and cannot flake in CI.

- `src/fuzz_tests.zig`: both targets registered in the `targets` table, and `targets` becomes `pub` for a new structural registration pin. An unregistered target compiles, passes review, and is then never run by any invocation; the pin asserts every target the release bar names is actually reachable from the runner, so the registration cannot rot.

- `src/dst.zig`: aggregates both new files plus `fuzz_tests.zig` into `zig build test`. This root is the one that reaches serve.zig and transport.zig relatively while carrying the `zit` dep those two import; `zig build fuzz` has no test step, so it is also the only place the registration pin can run.

No bug found, so no fix and no pinned-seed regression in this commit — both surfaces held over the seeded runs below. The targets are the standing check, not a one-time sweep.

Test evidence (this worktree, after `fossil update` absorbed the sibling `zit open` commit 5ba418801d and its 8 modified files):

- `zig build fuzz -- transport_frame_garbage --seed 1 --rounds 2000` — clean, no crash/leak/UB. - `zig build fuzz -- serve_request_garbage --seed 1 --rounds 2000` — clean, no crash/leak/UB. - `zig build test` — green, including the new registration pin and the fixed-seed smoke tests at the foot of both new files. - `zig build` and `zig fmt --check .` — green.

No borrowed technique in this change. check-in: cec4afaf89 user: dmestas tags: release-0.1

2026-07-18
23:11
Add zit open: clone→working-tree path, proven by e2e chain and fossil smoke scenario

`zit clone` builds a repository file and nothing else. The checkout it left behind was the same UNBORN record `init` writes (`version == ""`, branch `trunk`), with no managed set and no files, and there was no way to turn it into a usable tree: `zit update` accepts only a literal 40/64-hex check-in ID, so a freshly cloned tree could not name its own tip. Clone was a dead end.

`zit open` is the second half of fossil's own clone+open UX. It resolves the tip of the main branch out of this repository's derived state and hands materialization to the SAME `update.applyCheckin` engine every other working-tree move rides — journal-armed, transactional, rollback-safe. Tip resolution is the only work that is open's own; everything else is wiring.

What changed:

- `cli.parseOpenArgs` / `OpenArgs` / `OpenResult`: an operand-free surface. `-h`/`--help` is the only accepted token and wins over every other token; a bare `-x` is an unknown option, anything else an extra argument. The verb takes no operand by design — it derives its target from the repository. - `main.runOpen` plus the `open` entries in the `Verb` enum, the dispatch switch, and the `verb_help` table, so the verb is added in one place and the total switch stays total. `runOpen` writes NO checkout record and NO managed set of its own: `init` already wrote the unborn record, an absent managed set already reads as the empty set, and the update engine stays the single writer of the born record. A second writer of that bootstrap state would only be a second thing to keep in sync. - `main.openApplyFail`: one arm per `update.ApplyError` variant and no `else`, so a new variant fails the compile and forces a decision, mirroring `updateApplyFail`. `SymlinkUnsupported` / `InvalidPath` / `ReservedPath` are raised while planning the TARGET manifest and are reachable on a perfectly intact repository, so they get honest prose instead of a corruption claim. - `update.planUpdate`: an UNBORN record now takes the same arm as an absent one. Unborn is the normal state of every fresh repository, so without this the first `update` after a clone handed `""` to `buildPlan`, failed `artifact.validateName("")`, and reported `CurrentStateInvalid` — a clean, correct tree diagnosed as broken. A record that names no check-in but claims a merge parent or a pending special operation stays incoherent and is still `CurrentStateInvalid`. - BORN-ness, not presence, is the "already open" signal. A presence-based gate would have rejected a clone followed by an open on every clone, the primary path the verb exists to serve.

Test evidence (all run in this worktree after absorbing the sibling P3-F4 commit 281c0ed815, which arrived during this step and was merged by hand in `src/e2e.zig` — both sides had appended the identical P3-F4 block, so the duplicate was dropped):

- `zig build test` — green, including new `update_test.zig` coverage of the unborn-record arm and its incoherent-record rejections. - `zig build e2e` — green, including the P3-F3 block: clone → open → byte-identical tree → update → commit round-trip; a plain clone's unborn checkout tolerated by `update`; an untracked file in the way failing the open and materializing nothing; re-opening an already-open tree refused with no change; a repository with no check-in reported rather than failed; outside a repository failing with the not-a-repo diagnostic (exit 1); unknown option and positional as usage errors (exit 2); and the verb present in top-level help with one help text behind both `--help` and `help open`. - `zig build smoke` — green, with the new S6 scenario (§3.7/§4): `zit clone` + `zit open` against a REAL fossil server materializes the committed bytes byte-for-byte. This is the cross-implementation proof that the new path produces a working tree fossil itself agrees with. - `zig fmt --check .`, `zig build`, and `zig build dst` (6 seeds x 1000 steps) also green.

No borrowed technique in this change. No new file: the work lands entirely in existing modules. check-in: 5ba418801d user: dmestas tags: release-0.1