Repository navigation
Port the workbook tools to a Rust binary and run CI through it - #224
Merged
Merged
Conversation
`ozzit verify_cache` replaces tools/verify_cache.py. Native Excel stays the formula engine through the unchanged tools/dump_values.ps1; the command compares its dump with every cached value under the same tolerance, error-code map, floor and reporting limits. A recorded corpus of 76 Python findings covers dump parsing, value comparison, workbook shapes and dumper results. The 70 comparable cases match byte for byte; the six Python tracebacks become one FAIL line and exit 1. On native Excel both tools give byte-identical output for ozzit.xlsx (19,446 values) and for a copy with three stale values.
… Rust `ozzit sanitise_workbook`, `ozzit refresh_cache` and `ozzit polish_workbook` replace their Python tools. The refresh still drives Excel through tools/refresh_cache.ps1 and then sanitises the saved file; all three write through the existing deterministic archive writer. Recorded Python CLI results for 33 sanitiser and 31 presentation-pass fixtures match in output, result bytes and second run. On native Excel a refresh by each tool leaves byte-identical workbooks, equal to the committed ozzit.xlsx, and the cache gate passes on both. The presentation pass refuses a DOCTYPE and names the native XML parser's reason.
`ozzit generate_selftest_examples` writes the same tools/selftest_examples.ps1 as the Python generator; printed totals use exact decimals with Python's 28-digit context. `ozzit prepare_release_bundle` creates and verifies the copy-only bundle with the same argument handling, checks and messages. Its seven gates run as native child commands and are still recorded under the command names the provenance schema fixes. Recorded Python results cover 42 printed numbers, 600 generated help bodies, 192 stand-alone checks, 55 argument vectors and 91 bundle and manifest verification cases; all match. JSON loading can now report the first repeated member name.
Each pass in tools/postbuild runs as `ozzit <pass>` with its usual arguments and messages. The passes' text tables are exported once from the Python modules into src/data/postbuild/*.json, so no anchor is retyped; the logic is ported by hand. Three function-adding passes share one engine over the ported compiler, and the lease pass renders its definitions itself. Run by Python and Rust on all 48 historical workbook, src and index states, the 624 runs match in output and every resulting file's bytes, including 231 applied cases. Synthetic cases cover the FY27 pass, the template palette and the command-line errors; the only differences are the documented native ZIP reasons.
The banned-token scan ran entirely in fancy-regex's backtracking engine because one alternative used lookarounds. It took about 0.5 s and could exhaust the engine's backtrack limit on a 1 MB part, which panics. The scan is now a plain alternation with BXL's word boundaries checked by hand, rescanning one character on as Python's engine does. The cell scan's tempered loop is a plain lazy match, which ends at the same </c>. verify_workbook drops from about 640 ms to 165 ms (Python: 360 ms). Python's banned-token results on 30 edge inputs are replayed in a test, and verify_workbook matches Python on all 48 historical workbooks. The three copies of the word-boundary wrapper and two of fullmatch move to common.rs.
CI gains a rust job: it installs the toolchain rust-toolchain.toml pins, checks formatting, lints, runs the native tests, builds tools/ozzit once and runs the nine workbook gates with the binary. verify-gates requires it. The Python job keeps the lint, type check and unit tests, since the Python tools stay as the reference implementation until they are retired. RELEASING.md builds the native tools and runs the gates, the cached-value gate and the release bundle commands through them; the static gate count is now 15 and the release gate count 17. The postbuild run order and its prose name the native commands. AGENTS.md and CONTRIBUTING.md list the new CI sequence, and the repository policy and run-order tests follow it.
Codacy flagged 46 new issues on the Rust crate: Lizard function lengths and four Opengrep findings. Lizard reads a Rust lifetime such as 'a as the start of a character literal and loses track of braces, so its spans for Rust are wrong. Exclude *.rs and Cargo.lock from Lizard and enforce the same 50-line limit with clippy's too_many_lines lint, which CI already denies through -D warnings. Split the 57 functions over that limit into named steps without changing behaviour. The JSON decoder's loop becomes a small parser type, and the CSV reader's state machine a struct. Annotate the four Opengrep findings: current_exe locates the checkout or reruns this same binary for the gates, and the arguments only select a command.
The rust job now reruns each native verify_* gate it lists with its tools/*.py twin and fails if exit code or stdout differ, so the Python reference implementation cannot drift silently from the binary.
Codacy's Opengrep audit flags subprocess.run without a static string. The commands are argument lists with no shell: this interpreter or the built binary, a gate name and its arguments from the checked-in workflow. Record that beside the call, as the repository does for its other audited calls.
compare_gates.py now also runs every gate that reads a broken input on a temporary copy: functions.csv without a renamed function's row, src/Dates.txt with CountDOW's Starts parameter renamed, and ozzit.xlsx cut to half its length. Exit codes and stdout must match. The one allowed difference is the corrupt-ZIP reason each parser reports, which tools/ozzit/README.md documents.
An independent review found inputs where the native tools passed or wrote something the Python reference tools reject. Each case now behaves as Python 3.15 does: - int() and float() no longer strip U+001C to U+001F, so verify_cache no longer treats "100" followed by a separator as the cached 100. - verify_native_evidence subtracts two integer results exactly, so a claimed zero difference between ints that differ above 2**53 is refused. - remove_residue keeps sheet scopes and app.xml counts as arbitrary-precision ints and fails, writing nothing, on one Python's int() refuses. - aasb16_leases refuses an index row with no function field rather than writing it with an empty name. - The CSV reader takes a blank first record as DictReader's empty header, so a leading blank line no longer hides the function passes' store checks. - Captured process output that is not UTF-8 fails the run, whatever its exit status, as Python's strict text-mode decoding does. - The CSV writer quotes a carriage return; html.unescape's 4,300-digit limit on decimal references is kept; text attached to -h follows argparse. Documents the remaining fail-closed difference (lone JSON surrogates) and the Windows source-extension case, and corrects which implementation CI runs.
5 tasks done
ryanduguid
added a commit
that referenced
this pull request
Oct 19, 2026
## What this changes
Adds a manual `Attest release` workflow. It signs build provenance for
the tagged `ozzit.xlsx` and adds the bundle, `ozzit-X.Y.Z.intoto.jsonl`,
to the draft release before it is published. RELEASING.md places the
step.
```
gh workflow run attest-release.yml --ref vX.Y.Z
checks required_checks.py: verify.yml verify-gates succeeded on main for the tag commit
attest tag is a verified, signed, annotated vX.Y.Z on this commit
exactly one draft release holding SHA256SUMS, ozzit.xlsx and provenance.json, digests checked
prepare_release_bundle.py verify + the draft workbook equals git show <tag>:ozzit.xlsx
actions/attest on the tagged workbook, then gh attestation verify (API and bundle)
upload to the verified release ID only if the draft, its assets and the tag are unchanged
```
## Why
OpenSSF Scorecard scores Ozzit 0/10 on Signed-Releases, because releases
ship a custom `provenance.json` that Scorecard does not recognise. A
Sigstore `*.intoto.jsonl` bundle on the release is what it counts. The
workbook is built and gated with native Excel on Windows, so CI cannot
rebuild it. Instead this attests the tracked workbook at the signed tag,
after checking that the draft's copy is byte-identical. No computed
number, rate or threshold changes.
## Verification
```
python -m ruff check . # All checks passed!
python -m mypy --config-file mypy.ini # Success: no issues found in 34 source files
python -m unittest discover -s tools/tests # Ran 679 tests, OK (skipped=1)
actionlint .github/workflows/attest-release.yml # clean
```
`tools/tests/test_repository_policy.py` gains tests for the new
workflow's policy: its trigger, permissions, verification steps, upload
guards and the RELEASING.md step. Rust gates are unaffected: no file
under `tools/ozzit` changes.
## Final review
- Reviewed head commit: 92bb2cb
- Review type (self-review or independent): a Calvino review of the
original commits, then an independent agent review of the rebased branch
from a separate session (not a human review)
- Review record and scope: the full workflow, the RELEASING.md step and
the policy tests, after rebasing onto #224 (Rust workbook tools)
- Findings and disposition: Calvino requested changes and raised 5
findings. All were accepted and fixed in 60dc4a2 ("Bind the attestation
upload to the verified draft and signed tag name"), and one hypothesis
was left open in that review. The rebased review found one more issue,
fixed in 92bb2cb: both jobs ran on `ubuntu-latest`, which GitHub is
moving to Ubuntu 26, so they now pin `ubuntu-24.04`, as release-policy's
release jobs do.
- Provider results and coverage gaps: CI had not run when this was
written
- Remaining risks and unrun checks: the workflow has never run on
GitHub, so the signing and upload paths are unproven until the next
release's draft. The `checks` job uses release-policy a601f1d, the
commit Ozzit's VirusTotal workflow already pins. **Door:** two-way; the
workflow only runs when dispatched by hand. **Blast radius:** the next
release's draft.
## Checklist
- [x] No client, taxpayer, employee or payroll data, and no credentials,
tokens, tenant IDs or `.env` contents, appear in the diff, the commit
messages or this description.
- [x] The change is single-purpose.
- [x] New or changed behaviour has a test that fails without the change.
- [x] Documentation, README and any docstring that describes the changed
rule were updated together with it.
- [x] Any new dependency is justified in the description, or there is
none.
---------
Co-authored-by: Ryan Duguid <ryanduguid@users.noreply.github.com>
ryanduguid
added a commit
that referenced
this pull request
Oct 20, 2026
…th review (#231) ## Summary Fixes the places where the Rust tools in `tools/ozzit` disagreed with their Python twins, found by an independent review of the build, verifier, writer and release paths after #224 merged. Each case is an input where Rust wrote, passed or crashed while Python refused or succeeded. None occurs in the committed workbook or sources: the gates pass unchanged on `main`. ```text formula.rs /*/ no longer closes a block comment (compiler wrote 1+2 for 1/*/+2/**/) LET bindings keep brackets; [[x]] loses one pair (compiler wrote what Python refuses) help.rs verify_signatures skips hidden .txt modules (hidden files counted towards the floor) archive.rs members read as zipfile reads them (see below) common.rs no regex backtrack limit, as Python's re (sanitiser panicked after ~10 MB of sheet XML) spills.rs unterminated help literal drops one character (panicked inside a multibyte character) ``` The archive reader now walks the central directory itself instead of reading through the zip crate, which kept one record per raw filename: ```text open_zip zip crate finds and checks the end record only (same open-time diagnostics) └ records every central record in order, named as ZipInfo names it: own UTF-8 flag else cp437, cut at NUL, "\" to "/" on Windows, ZIP64 and Unicode Path extras; the last record wins a name read(name) only the selected record, from the one open file at explicit offsets: signature, flag bits 5 and 6, local name, overlap, encryption, then length and CRC writer refuses wherever Python would switch to ZIP64, including the file_size * 1.05 preflight ``` ```diff .github/ci/compare_gates.py - same when (exit code, stdout) match + same when (exit code, stdout, stderr) match; the documented ZIP reason may differ in either stream RELEASING.md + python .github/ci/compare_gates.py (16 static gates, 18 in all) ``` The crate changelog states the remaining differences exactly: no bzip2, LZMA or Zstandard members; a refusal wherever Python would write ZIP64; Python's two zipfile warnings are not printed. ## Evidence - **Before:** on `main` (8b5da82), against Python 3.15: - the compiler writes `_xlfn.LAMBDA(1+2)` where Python writes `_xlfn.LAMBDA(1)` - `LET([Bound],1,Bound)` and `LAMBDA([[Bound]],Bound)` are written where Python exits 1 - a hidden-only `src` passes `verify_signatures` where Python reports `no source modules found` - a workbook whose local header says `xl/workbool.xml` passes `verify_workbook` and is rewritten by `sanitise_workbook`, where Python raises `File name in directory 'xl/workbook.xml' and header b'xl/workbool.xml' differ.` - two records with the same name bytes but different UTF-8 flags, or a NUL in a name, hide a banned token from `verify_workbook` and drop a member in `sanitise_workbook` - `sanitise_workbook` exits 101 with `BacktrackLimitExceeded` on a sheet of 200,000 formula cells **After:** every one of those cases matches Python in exit code, output and written bytes, through `verify_workbook`, `sanitise_workbook` and `verify_cover`. Each fix has a regression test that fails on the code before it, and Python's zipfile gives what each new archive test asserts. `tools/tests/test_compare_gates.py` covers the comparator. - Windows: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (119 passed), the release build, ruff, mypy and the Python suite (`Ran 682 tests`, `OK (skipped=1)`) pass. The nine gates exit 0, and `compare_gates.py` reports all 22 comparisons the same (6 apart from the documented ZIP reason). - Linux (WSL Ubuntu, Rust 1.99.1, Python 3.14): the same Rust checks, gates and comparisons pass. The Python suite passes, with three tests that need a `.git` directory run in a clone. - Postbuild passes and `verify_workbook` on every historical workbook state (51 states): 714 of 714 runs match Python byte for byte. The existing Python suites with paired CLI runs: 170 invocations, all the same apart from documented usage text and parser reasons. - Gate timings on `ozzit.xlsx` are within 5 per cent of `main`. ## Merge danger **Door:** two-way. **Blast radius:** CI, tooling Only the native tools, the CI comparison script and `RELEASING.md` change. No workbook, source module, `functions.csv` or release evidence changes. The comparator is now stricter, so a future stderr difference fails CI where it passed before. The archive reader is new code on every gate's read path; the gates, comparisons and history runs above exercise it on every committed and historical workbook. ## Unverified - The independent review's third repair check, of the new archive reader (ee12d3b), was sent but its answer could not be captured: the review tool's client timed out and two reattach attempts failed. Its first two rounds reviewed everything else here. - Python 3.15 runs the unit suite but not `compare_gates.py`, which runs under 3.14, as on `main`. - No differential fuzzing beyond the fixtures above. --------- Co-authored-by: Ryan Duguid <ryanduguid@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
tools/ozzit, a standalone Rust binary that replaces the Python workbook tools, and runs CI,RELEASING.mdand the postbuild run order through it. The workbook, its formulas and the native Excel gates are unchanged; Excel stays the formula engine.The Python tools stay in the tree as the reference implementation and remain under test;
tools/pyxirr_comparison.pystays in Python because it compares against the independent pyxirr library.AGENTS.md,CONTRIBUTING.mdand the repository policy test list the new CI sequence, which the policy test requires.Evidence
verify_workbooktakes about 360 ms onozzit.xlsx.After: the same gates run by the binary give byte-identical stdout and exit codes;
verify_workbooktakes about 165 ms. Its banned-token scan no longer exhausts the regex engine's backtrack limit on the 1 MB AFE store part.ozzit.xlsx,src/andfunctions.csvstate in this repository (48 states): all 624 postbuild runs match in output and resulting bytes, including 231 that apply a change.verify_workbookmatches on all 48.refresh_cacheby each tool leaves byte-identical workbooks equal to the committedozzit.xlsx, andverify_cachereportsOK: all 19446 cached valuesfrom both. Release bundles created by each tool are byte-identical, and each tool verifies the other's..github/ci/check_gates.pyaccepts the job list, and therustjob's 14 commands pass on Windows and Linux (Rust 1.99.1, 86 tests).Documented differences from the Python tools, in
tools/ozzit/CHANGELOG.md: usage text names the native command, ZIP and XML parser errors give the native reason, argparse help is fixed at 80 columns, and an uncaught Python exception becomes oneFAIL:line with exit 1.Merge danger
Door: two-way. Blast radius: CI and the release procedure.
If the
rustjob fails on the runner,verify-gatesblocks merges until it is fixed or reverted. Workbook bytes,src/andfunctions.csvdo not change.Unverified
rustup toolchain installreadingrust-toolchain.tomlonubuntu-latest, and therustjob's time on a hosted runner.