Skip to content

Port the workbook tools to a Rust binary and run CI through it - #224

Merged
ryanduguid merged 18 commits into
mainfrom
ryan/rust-port-2026-10-18
Oct 19, 2026
Merged

ryanduguid merged 18 commits into
mainfrom
ryan/rust-port-2026-10-18

Conversation

@ryanduguid

Copy link
Copy Markdown
Owner

Summary

Adds tools/ozzit, a standalone Rust binary that replaces the Python workbook tools, and runs CI, RELEASING.md and the postbuild run order through it. The workbook, its formulas and the native Excel gates are unchanged; Excel stays the formula engine.

tools/ozzit/          one binary, `ozzit <command>`, same arguments as the Python tool it replaces
  src/                31 commands: 13 verifiers and gates, sanitise/refresh/polish, the release
                      bundle, the self-test generator and the 13 postbuild passes
  src/data/postbuild/ the passes' text tables, exported from the Python passes, not retyped
  tests/              recorded Python results replayed against the binary
 verify.yml
   lint     ruff, mypy                                   (unchanged)
-  verify   9 Python gates + unit tests on 3.14 and 3.15
+  verify   unit tests on 3.14 and 3.15
+  rust     toolchain from rust-toolchain.toml, fmt, clippy -D warnings, cargo test,
+           release build, then the 9 gates via tools/ozzit/target/release/ozzit
-  verify-gates needs [lint, verify, coverage]
+  verify-gates needs [lint, verify, rust, coverage]

The Python tools stay in the tree as the reference implementation and remain under test; tools/pyxirr_comparison.py stays in Python because it compares against the independent pyxirr library. AGENTS.md, CONTRIBUTING.md and the repository policy test list the new CI sequence, which the policy test requires.

Evidence

  • Before: gates run by the Python tools; verify_workbook takes about 360 ms on ozzit.xlsx.
    After: the same gates run by the binary give byte-identical stdout and exit codes; verify_workbook takes about 165 ms. Its banned-token scan no longer exhausts the regex engine's backtrack limit on the 1 MB AFE store part.
  • Writers and passes were run by Python and Rust on every historical ozzit.xlsx, src/ and functions.csv state in this repository (48 states): all 624 postbuild runs match in output and resulting bytes, including 231 that apply a change. verify_workbook matches on all 48.
  • Native Excel 16.0: refresh_cache by each tool leaves byte-identical workbooks equal to the committed ozzit.xlsx, and verify_cache reports OK: all 19446 cached values from both. Release bundles created by each tool are byte-identical, and each tool verifies the other's.
  • Locally on this branch: 678 Python tests pass (one skip), ruff and mypy are clean, .github/ci/check_gates.py accepts the job list, and the rust job'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 one FAIL: line with exit 1.

Merge danger

Door: two-way. Blast radius: CI and the release procedure.

If the rust job fails on the runner, verify-gates blocks merges until it is fixed or reverted. Workbook bytes, src/ and functions.csv do not change.

Unverified

  • This workflow has not run on GitHub Actions yet: rustup toolchain install reading rust-toolchain.toml on ubuntu-latest, and the rust job's time on a hosted runner.
  • macOS was not tested.
  • Security scanning of the new Rust dependencies was not rerun for this change; an earlier checkpoint's RustSec check covered the same lock file.

`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.
@ryanduguid
ryanduguid merged commit 23a2474 into main Oct 19, 2026
19 checks passed
@ryanduguid
ryanduguid deleted the ryan/rust-port-2026-10-18 branch October 19, 2026 21:23
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>
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