Skip to content

Port codegen-soundness fixes from harfbuzz/packtab#9#2

Merged
behdad merged 7 commits into
mainfrom
port-packtab-pr9-codegen-soundness
Jul 3, 2026
Merged

Port codegen-soundness fixes from harfbuzz/packtab#9#2
behdad merged 7 commits into
mainfrom
port-packtab-pr9-codegen-soundness

Conversation

@behdad

@behdad behdad commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Ports codegen/pruning soundness fixes from upstream harfbuzz/packtab into the Rust crates: the #9 set (found by round-trip fuzzing) plus the two applicable follow-up commits.

From PR #9 (one commit each)

  • F2 — inline packing for values ≥ 16 bits. The inline path packed each element at an 8-bit stride and sized the constant as len*8 bits, corrupting wide values (when unit_bits >= 8, data holds unit_bits-wide elements, not bytes). Now packs at elem_bits to match the extraction stride, and caps the const-type shift at 32 to avoid a 1 << 63 overflow.
  • F3 — return type must hold default. The lookup return type was sized from the stored data range only, so a negative or oversized default (emitted out-of-range / for culled default-prefix indices) would wrap in C or fail to compile in Rust. Widened to include default in both the direct and palette paths.
  • F4 — palette lookup ignored its shared-array start offset. Now honors the offset from add_array, like the data-array path. Latent through the public API (each generate() uses a fresh CodeBuilder); fixed for parity.
  • F1 — pruning discarded byte-minimal solutions. Pareto pruning kept only the (n_lookups, full_cost) frontier, so compression>=10 (min bytes) and compression<=0 (flat) couldn't reach the true optimum. Now prunes to the union of the full_cost and cost frontiers at every pruning site. The 1..9 heuristic is unaffected (the extra solutions are always full_cost-dominated).
  • F5 — N/A. The Python split() padding-aliasing bug can't occur here: the Rust port only handles i64 and always builds the inner chain from a freshly-owned Vec, with the bounds check keyed on the unpadded outer length. Added a guard test.

From follow-up commit 310edfc4f

  • Drop the dead padding byte from odd-length flat tables. build_layers padded layer.data in place, so odd-length flat (unsplit) tables emitted one unreachable trailing byte and the reported cost was one element short of the emission. Pad a local copy for pairing instead; split solutions and all picks are unchanged, odd-length flat arrays lose the dead byte (e.g. data_u8[10]data_u8[9]).
  • --analyze Score uses exact log2. The analyze table computed Score with floored ilog2 while pick_solution ranks with exact log2, so the highlighted "Best solution" could disagree with the minimum-score row. Now uses exact log2 (two decimals).

Two other post-#9 upstream commits are N/A: Repair dead Code.print_h (no C-header generation path in the Rust port) and Use NotImplemented as the infer-default sentinel (the Rust API takes Option<i64>; there's no dict/None-as-value input for None to collide with).

Tests

Existing tests that asserted the old single-frontier invariant are updated to the union check. New round-trip/frontier/regression tests cover every fix; each was confirmed to fail without its fix. Full workspace suite (187 tests, including the packtab-icu4x end-to-end round-trip over real Unicode data) passes.

🤖 Generated with Claude Code

behdad and others added 7 commits July 1, 2026 12:03
The inline codegen path packed each element at an 8-bit stride and sized
the constant as len*8 bits, corrupting wide values: when unit_bits >= 8,
`data` holds unit_bits-wide elements, not bytes. Pack at `elem_bits`
(= unit_bits when >= 8, else 8) to match the extraction stride, and cap
the const-type shift at 32 to avoid a `1 << 63` overflow.

Ports F2 from harfbuzz/packtab#9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lookup return type was sized from the stored data range only, but
`default` is emitted in the out-of-range branch and returned for culled
default-prefix indices. A negative or oversized default would wrap (C)
or fail to compile (Rust). Widen the range to include `default` in both
the direct and palette codegen paths.

Ports F3 from harfbuzz/packtab#9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The palette lookup indexed the palette array from zero, discarding the
start offset returned by add_array. When a palette array is shared across
solutions in one CodeBuilder, later solutions must index from their
offset — the same as the data-array path already does. Latent through
the public API (each generate() uses a fresh CodeBuilder), fixed for
parity and defensiveness.

Ports F4 from harfbuzz/packtab#9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pareto pruning kept only the (n_lookups, full_cost) frontier, discarding
byte-minimal solutions before they reached the root. This left the
compression>=10 (min bytes) and compression<=0 (flat) selectors unable
to reach the true optimum.

Prune to the union of the (n_lookups, full_cost) and (n_lookups, cost)
frontiers via a shared dual_frontier_indices helper, applied at every
pruning site (inner intermediate/root layers and the outer direct+palette
set). The size/speed heuristic (1..9) is unaffected: the extra
cost-frontier solutions are always full_cost-dominated, and ties order
the full_cost incumbent first. Existing tests that asserted the old
single-frontier invariant are updated to the union check.

Ports F1 from harfbuzz/packtab#9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-trip and frontier regressions covering the ported fixes: inline
wide-value packing (F2), default-value type widening incl. negative/
oversized/culled-prefix defaults (F3), odd-length out-of-range → default
(F5-analog; the inner chain never aliases the outer data in Rust), and
compression>=10 reaching global min bytes while 1..9 picks stay unchanged
(F1).

Ports the test additions from harfbuzz/packtab#9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build_layers padded layer.data in place so the flat (unsplit) solution
emitted odd-length tables with one unreachable trailing byte, while the
cost — computed from the un-padded length — was one element short of the
emission. Pad a local copy for pairing instead; layer.data keeps the
original array, so the flat solution emits exactly len(data) elements and
its cost matches. Split solutions still pair over the optimally-padded
copy, so all costs and pick_solution choices are unchanged.

Ports part of harfbuzz/packtab@310edfc4f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--analyze computed the displayed Score with floor(log2) via ilog2, while
pick_solution ranks 1..9 with exact log2, so the highlighted "Best
solution" could disagree with the minimum-score row. Use exact log2 (and
two decimals) so the ranking is visibly consistent.

Ports part of harfbuzz/packtab@310edfc4f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@behdad behdad merged commit 8cb79f5 into main Jul 3, 2026
nicoburns pushed a commit to nicoburns/parley that referenced this pull request Jul 6, 2026
## Intent

Upgrade to the new PackTab 1.9.0.

---

I previously tested the PR (behdad/packtab.rs#2)
in
linebender@22bbd1d
prior to updating this PR to use the new 1.9.0 version. That
investigation is below:

Tested against cases our existing `--compression=5`, but also
`--compression=0` and `--compression=10`. Our regressions tests pass for
all cases - so, it's correct as far as Parley is concerned.

At compression=10, I noticed a 7 byte reduction relative to current
PackTab - I believe that's expected because the proposed PR drops dead
padding bytes
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