bindery-cli 0.41.0__tar.gz → 0.42.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/.github/workflows/publish.yml +7 -2
  2. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/CLAUDE.md +1 -1
  3. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/PKG-INFO +1 -1
  4. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/patchnotes.md +45 -0
  5. bindery_cli-0.42.0/project.done +64 -0
  6. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/pyproject.toml +1 -1
  7. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/roadmap.md +52 -4
  8. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/__init__.py +1 -1
  9. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/epub.py +15 -12
  10. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/transforms.py +24 -7
  11. bindery_cli-0.42.0/tests/test_matcher_hardening.py +165 -0
  12. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_transforms.py +32 -0
  13. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/uv.lock +1 -1
  14. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/.github/workflows/ci.yml +0 -0
  15. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/.gitignore +0 -0
  16. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/AGENTS.md +0 -0
  17. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/LICENSE +0 -0
  18. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/README.md +0 -0
  19. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/docs/screenshots/library-sweep.png +0 -0
  20. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/logo.svg +0 -0
  21. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/plugin/__init__.py +0 -0
  22. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/run_tests.sh +0 -0
  23. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/scripts/FastSweep.java +0 -0
  24. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/scripts/build_plugin.py +0 -0
  25. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/scripts/fast_sweep.py +0 -0
  26. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/spec.md +0 -0
  27. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/__main__.py +0 -0
  28. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/audit.py +0 -0
  29. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/cli.py +0 -0
  30. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/library.py +0 -0
  31. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/pagination.py +0 -0
  32. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/reserialize.py +0 -0
  33. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/validate.py +0 -0
  34. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/src/bindery/watermark.py +0 -0
  35. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  36. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_audit.py +0 -0
  37. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_cli.py +0 -0
  38. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_epub.py +0 -0
  39. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_library.py +0 -0
  40. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_pagination.py +0 -0
  41. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_plugin.py +0 -0
  42. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_reserialize.py +0 -0
  43. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_validate.py +0 -0
  44. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_version.py +0 -0
  45. {bindery_cli-0.41.0 → bindery_cli-0.42.0}/tests/test_watermark.py +0 -0
@@ -73,8 +73,13 @@ jobs:
73
73
 
74
74
  # The tag message IS the release's patchnotes entry (verbatim, per the
75
75
  # tagging procedure), so the release body is extracted from the tag
76
- # object rather than paraphrased here. Without it the release page
77
- # renders blank beside the zip.
76
+ # object rather than paraphrased here. The checkout is depth-1 and
77
+ # leaves a LIGHTWEIGHT local tag on the commit (whose %(contents) is
78
+ # the commit message, not the release entry), so the real annotated
79
+ # tag object is force-fetched from the origin before reading it.
80
+ - name: Fetch the tag object
81
+ run: git fetch --no-tags --force origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
82
+
78
83
  - name: Extract the release body from the tag message
79
84
  run: git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" > release-body.md
80
85
 
@@ -82,7 +82,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
82
82
 
83
83
  ## Layout
84
84
 
85
- - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
85
+ - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such greedy loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.42.0 the census is complete and pinned by tests/test_matcher_hardening.py: every greedy loop of this family is possessive (the three 0.41.1 rewrites plus _START_TAG_RE, _COVER_META_RE, _GUIDE_REF_RE, prune_dangling_edges' spine|item|itemref matcher, _ITEM_TAG_RE, _SPINE_ITEM_RE -- possessive is byte-identical on success because the loop is always followed by a literal `>` the [^>] branch cannot consume), and the five LAZY siblings (_VOID_RE, the transforms <img> matcher, and epub's link/a/img matchers) use the disjoint catch-all `[^>"']` instead: mutually exclusive branches cannot re-partition, and an unterminated quote inside a tag now fails to match rather than pairing across markup. NEW tag matchers: quote-aware possessive for greedy loops, the disjoint catch-all for lazy ones, and the tempered-quote idiom (["'])((?:(?!\1).)*)\1 for attribute values. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
86
86
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
87
87
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
88
88
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.41.0
3
+ Version: 0.42.0
4
4
  Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
5
  Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
6
6
  Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
@@ -1,4 +1,49 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.42.0 (2026-09-16)
3
+
4
+ ### The matcher audit executed: every quote-aware tag matcher hardened
5
+ The follow-up box the 0.41.1 hotfix opened is closed with a full per-matcher census, and the audit found the box's own enumeration incomplete. Six GREEDY patterns of the exponential overlap class were unhardened -- `_START_TAG_RE` (the quote-aware start-tag matcher anchoring `--strip-epub3-attrs` and `--strip-bad-attrs`, running under `repair --all` on every content doc; not even named in the box), `_COVER_META_RE`, `_GUIDE_REF_RE`, `prune_dangling_edges`' spine|item|itemref matcher, `_ITEM_TAG_RE`, and `_SPINE_ITEM_RE`. All six are possessive-bounded now: byte-identical on every successful match (the loop is always followed by a literal `>` that the `[^>]` branch cannot consume, so backtracking only ever happened on failure) and linear on failure. Five LAZY patterns of the quadratic class (`_VOID_RE`, the transforms `<img>` matcher, and epub's link/anchor/img matchers) took the disjoint catch-all `[^>"']` instead of a blind possessive swap: mutually exclusive branches cannot re-partition, so any quantifier is linear, and an unterminated quote inside a tag now fails to match instead of pairing with a distant quote and matching across markup. Every well-formed shape is byte-identical. The epub `_IMG_TAG_RE` is renamed `_PRUNE_IMG_TAG_RE` (it collided with the transforms matcher of the same name). Everything else in the package audited safe by construction: tempered-quote value matchers, two-branch quote alternations, `[^>]*` single-class prefixes (linear, not quote-aware -- accepted, documented), lazy paired-delimiter spans, and the analyzer tokenizers.
6
+
7
+ Pinned by `tests/test_matcher_hardening.py` (15 tests): alarm-guarded spin shapes per greedy site (OPF flavor, the truncated-OPF prune shape), unterminated-quote skips, and well-formed byte-identity. `CLAUDE.md`'s hazard note now carries the census and the rule for new matchers. The full suite is 491 tests.
8
+
9
+ ## v0.41.1 (2026-09-15)
10
+
11
+ ### The 0.41.0 CPU-spin regression: possessive bounds on the quote-aware tag matchers
12
+
13
+ - **The regression.** `repair --all` spun CPU indefinitely on real
14
+ books (found on a Hackett Aristotle *Physics* whose 843k-character
15
+ back-matter document is full of apostrophes and span candidates
16
+ whose closer never comes). 0.40.0 repaired the same book in seconds.
17
+ Brandon bisected it to 0.41.0 and root-caused it to the wheel diff:
18
+ the 0.41.0 quote-aware rewrite of `unwrap_block_in_inline`'s start
19
+ tag replaced the linear prefix with an overlapping alternation
20
+ (double-quoted | single-quoted | any-non-bracket), and because the
21
+ non-bracket branch also matches quote characters, every failing
22
+ candidate let the engine re-partition the prefix across the branches
23
+ exponentially. The flag-bisect had looked interaction-shaped because
24
+ earlier transforms move where the failing candidates sit; the regex
25
+ was input-sensitive, not the flags broken.
26
+ - **The fix: possessive quantifiers.** The repeated group is now
27
+ possessive (`*+`, Python 3.11's `re`, the same floor as the plugin's
28
+ minimum Calibre): the engine commits to its first-alternative
29
+ consumption, which is the nearest-paired-quote path ending at the
30
+ first unquoted `>` (the correct tag end), and never re-partitions.
31
+ Failure becomes linear; successful matches are unchanged. Applied to
32
+ all three patterns the 0.41.0 commit rewrote:
33
+ `unwrap_block_in_inline` (the spinner), `unwrap_illegal_tags`, and
34
+ `downgrade_epub3_tags`. The pre-existing quote-aware matchers
35
+ (`_VOID_RE`, the OPF tag matchers) were tested against the same
36
+ shape and do not spin; their audit is a recorded follow-up box.
37
+ - **Verified on the fixture.** The transform went from still-spinning
38
+ at 15 seconds to 0.01 seconds; the recorded reproduction
39
+ (`repair --all --force`) completes in 7.7 seconds (epubcheck's own
40
+ time) with output identical to the 0.40.0 shape
41
+ (cover_meta_repointed:1, pagelist_class_added:1, ncx_uid_synced).
42
+ Two regression tests pin it: the spin shape itself (a span followed
43
+ by a 40k-character apostrophe run with no `>`: the old pattern
44
+ provably cannot finish it) and the quote-aware function the rewrite
45
+ was for (a `>` inside an attribute value).
46
+
2
47
  ## v0.41.0 (2026-09-15)
3
48
 
4
49
  ### The final-audit blitz: robust analyzers, the stub-docs strip, and a privacy untrack
@@ -0,0 +1,64 @@
1
+ # bindery-cli: project.done
2
+
3
+ Completed: 2026-09-15 (THE FINAL BLITZ, repo agent lane)
4
+ Range: 2b9f24e (analyzer trio) through f47fc8f (ci tag-fetch fix), on main,
5
+ pushed; CI green throughout; v0.41.0 tagged and published to PyPI.
6
+
7
+ POSTSCRIPT 2026-09-15 (same day, Brandon's regression report): v0.41.1
8
+ hotfixed the one real defect the blitz shipped, the catastrophic-backtracking
9
+ CPU spin in `repair --all`'s rewritten quote-aware span matcher (the Physics
10
+ fixture; Brandon bisected and root-caused it). Fix: possessive quantifiers
11
+ on all three rewritten tag-prefix patterns; verified on his fixture against
12
+ the 0.40.0 output shape; the follow-up matcher-audit box is in the roadmap.
13
+
14
+ ## What shipped
15
+
16
+ - The analyzer-robustness trio: spineless-EPUB verdicts, per-book try
17
+ coverage, the corrupt-container CORRUPT verdict, the DRM false-EMPTY fix.
18
+ - The phase3 aggregate-summary fix (applied/equal states only,
19
+ rejected_projection line, Ghost Brigades regression test).
20
+ - Six features: `bindery doctor`, `repair --json FILE`, `--strip-stub-docs`
21
+ (the lossy lane's live fixture, Brandon-approved), plugin log rotation,
22
+ `--backup-keep N` ring, the fast_sweep count-scale pin.
23
+ - The robustness LOW batch (quote-aware matchers, raw-name resolution,
24
+ atomic backups, rglob filter, mojibake-uid guard, daemon tempdir sweep,
25
+ apply-failure accounting, relative_to guard).
26
+ - The privacy untrack of testing_facility/top500candidates/REPORT.md
27
+ (forward-only; Brandon's ruling).
28
+ - The GitHub batch: release bodies via body_path + three backfills, the
29
+ description rewrite, [project.urls], topics swap, wiki off, the
30
+ redundant suite run removed, concurrency blocks, checkout alignment,
31
+ the release action SHA-pinned; plus a v*.*.* tag ruleset (admin bypass)
32
+ and the pypi environment restricted to tag pushes (Brandon's rulings).
33
+ - Docs truth batches: one canonical repair inventory in spec.md, the audit
34
+ exit-code contract, the epubcheck install paragraph, the flag-list move,
35
+ the mimetype-fix counts, the degrades-safely correction, the
36
+ comment/docstring truth pass, the em-dash sweep and the "honest" tic.
37
+ - Dead code removed on a recorded go: validate's `_daemon` alias, cli's
38
+ `done_count`, audit's `sys.path.insert`.
39
+ - Hygiene: REPORT-12-Sept.md retired per the BINDERY_REPORT precedent; the
40
+ derived litter deleted (test_facility/__pycache__, FastSweepExtract.class,
41
+ venv/ + .venv_ci/).
42
+ - Tests: 445 -> 474, all green; ruff 0.16.2 clean.
43
+
44
+ ## Reopen conditions (recorded future work)
45
+
46
+ 1. top500candidates/REPORT.md history strip: declined (forward-only only).
47
+ If ever wanted: filter-repo the file out, re-tag every affected release
48
+ verbatim, disable publish.yml around the push (force-push class).
49
+ 2. Stranger-facing XML hardening (dependency ask pending): a stdlib
50
+ per-entry size cap in load_book plus defusedxml-style entity-expansion
51
+ gating. Trigger: a real 3.12/3.13 stranger audience. Roadmap boxed.
52
+ 3. MobileRead listing for the Bindery Repair plugin: Brandon's manual
53
+ posting step; roadmap boxed with the material listed.
54
+ 4. README screenshot regeneration against a synthetic library: pending
55
+ Brandon's call; the current PNG still shows real library rows.
56
+ 5. Boxed M feature set: --encode-url-spaces entry renames (280-book
57
+ PKG-010 class), the cover advisory analyzer, the audit-refresh
58
+ deep-dive, the NCX<->nav drift detector.
59
+ 6. Boxed internal refactors: RepairFlags dataclass for the triplicated
60
+ kwarg signature; shared roman/arabic number helpers for pagination.py
61
+ and audit.py.
62
+ 7. Cross-repo note under decision #117: cquarry, CalibreQuarry, and
63
+ vir-tui should mirror the tag ruleset + pypi environment policy this
64
+ repo adopted.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.41.0"
7
+ version = "0.42.0"
8
8
  description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
9
9
  readme = "README.md"
10
10
  classifiers = [
@@ -1642,7 +1642,7 @@ four interpreter families.
1642
1642
  (the oldest series whose embedded interpreter CI covers; the hollow
1643
1643
  (2, 0, 0) is gone) and the README plugin section documents platforms
1644
1644
  (Linux-tested) and the verification story.
1645
- - [ ] **Analyzer robustness on untrusted input:** spine_integrity
1645
+ - [x] **Analyzer robustness on untrusted input:** *(SHIPPED in v0.41.0 -- the analyzer-robustness trio; verified 2026-09-16 by the post-blitz audit run: per-book verdicts over truncated/renamed-zip/zero-byte fixtures with no run abort.)* ** spine_integrity
1646
1646
  IndexErrors on a spineless EPUB (nums[0] with an empty list) and
1647
1647
  the call sites sit OUTSIDE the per-book try - one malformed book
1648
1648
  aborts a whole run; load_book raises NameError (encrypted_names
@@ -1671,7 +1671,7 @@ four interpreter families.
1671
1671
  --encode-url-spaces entry renames (the reference-rewrite half
1672
1672
  shipped v0.28.0; the remainder is renaming archive entries whose
1673
1673
  filenames carry raw spaces; PKG-010, 280 books); plugin log rotation.
1674
- - [ ] **GitHub presentation (workspace batch):** description rewrite
1674
+ - [x] **GitHub presentation (workspace batch):** *(EXECUTED and verified at the ticked box below (line ~1745) during the blitz; ticked 2026-09-16 to stop it reading as open work.)* ** description rewrite
1675
1675
  (leads with deterministic epubcheck-gated repair); 7 topics swapped
1676
1676
  (add epub3/calibre-plugin/python-cli/ebook-audit); codex page still
1677
1677
  says calibredb; wiki off.
@@ -1690,7 +1690,7 @@ four interpreter families.
1690
1690
  real 3.12/3.13 (258 tests per leg, verified locally under
1691
1691
  3.12.14/3.13.15). Suite 445, ruff 0.16.2 clean.
1692
1692
 
1693
- - [ ] **Candidate (lossy lane): strip duplicate stub spine docs.** Opened
1693
+ - [x] **Candidate (lossy lane): strip duplicate stub spine docs.** *(SHIPPED as --strip-stub-docs (the ticked box at ~1796); verified 2026-09-16: the no_worse gate correctly refuses a fabricated regression-stub.)* ** Opened
1694
1694
  2026-09-13 from the phase-1 Redwall-fleet successor run: The River
1695
1695
  Has Roots (bookmate platform conversion) carries 14 of 37 spine
1696
1696
  docs that are identical stubs (the same short boilerplate body
@@ -1708,7 +1708,7 @@ four interpreter families.
1708
1708
  (kept in ~/Downloads pending Brandon's import-or-re-source call);
1709
1709
  the 2026-09-13 phase-1 JSON has the analyzer verdicts.
1710
1710
 
1711
- - [ ] **`run phase3`'s aggregate before/after summary mixes a rejected
1711
+ - [x] **`run phase3`'s aggregate before/after summary mixes a rejected
1712
1712
  candidate's projected after-state into the totals** (observed
1713
1713
  2026-09-13, OMW batch): Ghost Brigades measured 0f/47e -> 1f/24e
1714
1714
  REGRESSION and was correctly rejected, untouched; the only applied
@@ -1849,3 +1849,51 @@ four interpreter families.
1849
1849
  stdlib-only rule and adds a vendored plugin dep): trigger is a
1850
1850
  real 3.12/3.13 stranger audience, not a hypothetical.
1851
1851
  - [x] **vir-tui floor >=2.5.0 + lock refresh** (2026-09-14, no release cut: upstream 2.4.0/2.5.0 are additive terminal-safety and session-awareness releases; only the vir-tui resolution moved in the lock, the cquarry lag rule is untouched). *(Mention the floor bump in the next release's patchnotes.)*
1852
+
1853
+ - [x] **REGRESSION (0.41.0) FIXED in v0.41.1: the repair --all CPU
1854
+ spin on the Physics fixture was catastrophic backtracking in the
1855
+ rewritten `unwrap_block_in_inline` regex** (SHIPPED 2026-09-15,
1856
+ 176906f): the quote-aware alternation's branches overlap ([^>]
1857
+ also matches quotes), so failing candidates re-partitioned the
1858
+ prefix exponentially. All three patterns the 0.41.0 commit
1859
+ rewrote (`unwrap_block_in_inline`, `unwrap_illegal_tags`,
1860
+ `downgrade_epub3_tags`) are now possessive-bounded (`*+`, re
1861
+ 3.11+ = the plugin's minimum Calibre floor): the committed
1862
+ first-alternative path is the correct tag end, failure is
1863
+ linear, successful matches are unchanged. Verified on Brandon's
1864
+ fixture: the transform went from still-spinning at 15s to
1865
+ 0.01s; the recorded repro (`repair --all --force`) completes in
1866
+ 7.7s with output identical to the 0.40.0 shape. Regression
1867
+ tests: the spin shape (the old pattern provably cannot finish
1868
+ it) and the quote-aware function. Root-cause note preserved from
1869
+ the report: the flag-bisect looked interaction-shaped because
1870
+ earlier transforms move where failing candidates sit; the regex
1871
+ was input-sensitive, not the flags broken. Unpin 0.40.0 once
1872
+ 0.41.1 is installed.
1873
+ - [x] **Audit the remaining quote-aware tag matchers for the same
1874
+ overlap ambiguity** *(EXECUTED 2026-09-16, v0.42.0 -- the full
1875
+ per-matcher census, one ruling each: the audit found the box's own
1876
+ enumeration incomplete. Six GREEDY exponential-class sites were
1877
+ unhardened, possessive-bounded now (success is byte-identical:
1878
+ the loop is always followed by a literal `>` that `[^>]` cannot
1879
+ consume): transforms.py `_START_TAG_RE` -- the true gap, not even
1880
+ named in the box, running under repair --all on every content doc;
1881
+ `_COVER_META_RE`; `_GUIDE_REF_RE`; prune_dangling_edges'
1882
+ spine|item|itemref matcher; `_ITEM_TAG_RE`; `_SPINE_ITEM_RE`.
1883
+ Five LAZY quadratic-class sites took the disjoint catch-all
1884
+ `[^>"']` instead of a blind possessive swap (a lazy `*?` ->
1885
+ `*+` is NOT byte-identical with the trailing `\s*(/?)>`
1886
+ continuation, which peels whitespace and the self-close slash):
1887
+ `_VOID_RE`, the transforms `<img>` matcher, and epub's
1888
+ link/a/img matchers -- mutually exclusive branches make any
1889
+ quantifier linear and fix the far-match wart: an unterminated
1890
+ quote inside a tag now fails to match instead of pairing across
1891
+ markup. The epub `_IMG_TAG_RE` renamed `_PRUNE_IMG_TAG_RE` (name
1892
+ collision with the transforms one). Everything else audited safe
1893
+ by construction: tempered-quote value matchers, two-branch
1894
+ quote alternations, `[^>]*` single-class prefixes (linear,
1895
+ not quote-aware -- accepted), lazy paired-delimiter spans, and
1896
+ the analyzer tokenizers. Pinned by tests/test_matcher_hardening.py:
1897
+ 15 tests (spin shapes alarm-guarded per greedy site, unterminated-
1898
+ quote skips, well-formed byte-identity, prune spin over the
1899
+ truncated-OPF shape). Never a third grammar copy.)*
@@ -11,7 +11,7 @@ Calibre plugin built from this core.
11
11
  Single source of truth for the version. pyproject.toml must match VERSION below.
12
12
  """
13
13
 
14
- VERSION = "0.41.0"
14
+ VERSION = "0.42.0"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -316,14 +316,14 @@ _EDGE_ATTR_RES = (
316
316
  re.compile(r"""\s+fallback\s*=\s*(?:"[^"]*"|'[^']*')""", re.IGNORECASE),
317
317
  )
318
318
  _COVER_META_RE = re.compile(
319
- r"""<meta\b(?=(?:[^>]*\bname=)["']cover["'])(?:(?:"[^"]*"|'[^']*'|[^>])*)>""",
319
+ r"""<meta\b(?=(?:[^>]*\bname=)["']cover["'])(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""",
320
320
  re.IGNORECASE,
321
321
  )
322
322
  _COVER_CONTENT_RE = re.compile(
323
323
  r"""\s+content\s*=\s*(["'])((?:(?!\1).)+)\1""", re.IGNORECASE
324
324
  )
325
325
  _GUIDE_REF_RE = re.compile(
326
- r"""<reference\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""", re.IGNORECASE
326
+ r"""<reference\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE
327
327
  )
328
328
 
329
329
 
@@ -421,7 +421,7 @@ def prune_dangling_edges(opf_text: str, pruned_ids: set[str]) -> tuple[str, int]
421
421
  return tag
422
422
 
423
423
  out = re.sub(
424
- r"""<(?:spine|item|itemref)\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""",
424
+ r"""<(?:spine|item|itemref)\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""",
425
425
  strip_attrs,
426
426
  opf_text,
427
427
  flags=re.IGNORECASE,
@@ -588,9 +588,12 @@ def downgrade_epub3_tags(
588
588
  return f"</{new}>"
589
589
 
590
590
  # quote-aware attrs group (same shape as the manifest matchers): a
591
- # `>` inside an attribute value must not end the tag early
591
+ # `>` inside an attribute value must not end the tag early. The
592
+ # branches overlap ([^>] also matches quotes), so the possessive
593
+ # `*+` bounds the re-partitioning that spun CPU on failing
594
+ # candidates in 0.41.0 (see transforms.unwrap_block_in_inline).
592
595
  text = re.sub(
593
- rf"""<{tag}\b((?:"[^"]*"|'[^']*'|[^>])*)>""",
596
+ rf"""<{tag}\b((?:"[^"]*"|'[^']*'|[^>])*+)>""",
594
597
  repl_open,
595
598
  text,
596
599
  flags=re.IGNORECASE,
@@ -656,21 +659,21 @@ _DROP_HREF_ATTR_RE = re.compile(r"""\s+href\s*=\s*(?:"[^"]*"|'[^']*')""", re.IGN
656
659
  # shape as transforms._VOID_RE, so a '>' inside an attribute value cannot end
657
660
  # the tag early.
658
661
  _LINK_TAG_RE = re.compile(
659
- r"""<link(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
662
+ r"""<link(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
660
663
  re.IGNORECASE | re.DOTALL,
661
664
  )
662
665
  _ANCHOR_TAG_RE = re.compile(
663
- r"""<a(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""", re.IGNORECASE | re.DOTALL
666
+ r"""<a(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""", re.IGNORECASE | re.DOTALL
664
667
  )
665
- _IMG_TAG_RE = re.compile(
666
- r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
668
+ _PRUNE_IMG_TAG_RE = re.compile(
669
+ r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
667
670
  re.IGNORECASE | re.DOTALL,
668
671
  )
669
- _ITEM_TAG_RE = re.compile(r"""<item\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""", re.IGNORECASE)
672
+ _ITEM_TAG_RE = re.compile(r"""<item\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE)
670
673
  # `<itemref` must not be swallowed by the `<item\b` matcher above (\b has no
671
674
  # boundary before the 'r'), and its own idref carries the spine order.
672
675
  _SPINE_ITEM_RE = re.compile(
673
- r"""<itemref\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""", re.IGNORECASE
676
+ r"""<itemref\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE
674
677
  )
675
678
  # `\b` is what keeps `<item\b` from matching `<itemref`: "itemref" has a word
676
679
  # character right after "item", so there is no boundary there.
@@ -996,7 +999,7 @@ def prune_missing_images(
996
999
  counts[key] = counts.get(key, 0) + 1
997
1000
  return alt if key == "missing_imgs_unwrapped" else ""
998
1001
 
999
- return _IMG_TAG_RE.sub(repl, part), counts
1002
+ return _PRUNE_IMG_TAG_RE.sub(repl, part), counts
1000
1003
 
1001
1004
  total: dict[str, int] = {}
1002
1005
  if "<!" in text:
@@ -32,14 +32,14 @@ XML_PREDEFINED = {"amp", "lt", "gt", "quot", "apos"}
32
32
  # quote-aware so a `>` inside an attribute value does not end the tag early. Group 3
33
33
  # captures an existing trailing slash so already-self-closed tags are left untouched.
34
34
  _VOID_RE = re.compile(
35
- rf"""<({VOID})(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
35
+ rf"""<({VOID})(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
36
36
  re.IGNORECASE | re.DOTALL,
37
37
  )
38
38
  _VOID_END_RE = re.compile(rf"""</(?:{VOID})\s*>""", re.IGNORECASE)
39
39
  _NAMED_ENTITY_RE = re.compile(r"&([a-zA-Z][a-zA-Z0-9]*);")
40
40
  _BARE_AMP_RE = re.compile(r"&(?![a-zA-Z][a-zA-Z0-9]*;|#[0-9]+;|#[xX][0-9a-fA-F]+;)")
41
41
  # A start tag (quote-aware) and an attribute within it, for invalid-attribute stripping.
42
- _START_TAG_RE = re.compile(r"""<[a-zA-Z][\w:.-]*(?:"[^"]*"|'[^']*'|[^>])*>""")
42
+ _START_TAG_RE = re.compile(r"""<[a-zA-Z][\w:.-]*(?:"[^"]*"|'[^']*'|[^>])*+>""")
43
43
  # The unquoted-value branch stops at whitespace or the end of the tag, and the `/?>`
44
44
  # lookahead is what keeps it off the tag's own self-closing slash: a plain `[^\s>]+`
45
45
  # swallowed it, so dropping the attribute in `<img 31=x/>` left `<img>` and turned a
@@ -235,7 +235,7 @@ def escape_unknown_entities(s: str) -> tuple[str, int]:
235
235
  # An <img> start tag, quote-aware like _VOID_RE; group 1 is the attribute block and
236
236
  # group 2 an existing self-closing slash, so the tag is rebuilt without reordering.
237
237
  _IMG_TAG_RE = re.compile(
238
- r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
238
+ r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
239
239
  re.IGNORECASE | re.DOTALL,
240
240
  )
241
241
  # Whitespace before `alt` keeps data-alt/xml:alt-style names from matching; a quoted
@@ -374,8 +374,19 @@ def unwrap_block_in_inline(s: str) -> tuple[str, int]:
374
374
  return m.group(1)
375
375
 
376
376
  s, n = re.subn(
377
- r"""<span(?:(?:"[^"]*"|'[^']*'|[^>])*)>\s*"""
378
- r"""(<(div|p|blockquote)(?:(?:"[^"]*"|'[^']*'|[^>])*)>.*?</\2>)\s*</span>""",
377
+ # The quote-aware branches OVERLAP ([^>] also matches quote
378
+ # characters), so a plain `*` lets the engine re-partition the start
379
+ # tag exponentially across them on every failing candidate: on a
380
+ # 843k-char doc full of <span>s whose closer never comes, 0.41.0
381
+ # spun CPU indefinitely (the Physics-fixture regression,
382
+ # roadmap 2026-09-15). The possessive `*+` commits to the
383
+ # first-alternative consumption (ending at the first unquoted `>`,
384
+ # which is the correct tag end) and never re-partitions: linear on
385
+ # failure, same matches on success. `re` has had possessive
386
+ # quantifiers since 3.11, the same floor as the plugin's minimum
387
+ # Calibre.
388
+ r"""<span(?:(?:"[^"]*"|'[^']*'|[^>])*+)>\s*"""
389
+ r"""(<(div|p|blockquote)(?:(?:"[^"]*"|'[^']*'|[^>])*+)>.*?</\2>)\s*</span>""",
379
390
  repl,
380
391
  s,
381
392
  flags=re.IGNORECASE | re.DOTALL,
@@ -503,9 +514,15 @@ def unwrap_illegal_tags(
503
514
  continue
504
515
  # quote-aware start-tag match (same shape as _VOID_RE): a `>` inside
505
516
  # an attribute value would otherwise end the match early and mangle
506
- # the tag into a malformed edit the gate then rejects
517
+ # the tag into a malformed edit the gate then rejects. The branches
518
+ # overlap ([^>] also matches quotes), so the possessive `*+` bounds
519
+ # the re-partitioning that spun CPU on failing candidates in 0.41.0
520
+ # (see unwrap_block_in_inline).
507
521
  s, n1 = re.subn(
508
- rf"""<{tag}\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""", "", s, flags=re.IGNORECASE
522
+ rf"""<{tag}\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""",
523
+ "",
524
+ s,
525
+ flags=re.IGNORECASE,
509
526
  )
510
527
  s, n2 = re.subn(rf"</{tag}\s*>", "", s, flags=re.IGNORECASE)
511
528
  count += n1 + n2
@@ -0,0 +1,165 @@
1
+ """Regression net for the 2026-09-16 per-matcher hardening (the roadmap's
2
+ matcher-audit follow-up box, executed).
3
+
4
+ The 0.41.1 hotfix made three rewritten patterns possessive; the audit that
5
+ followed found six more greedy patterns of the same exponential class
6
+ (the quote-aware loop whose branches overlap) and five lazy patterns of
7
+ the quadratic class. The greedy loops are now possessive `*+` (byte-
8
+ identical on success: the loop is always followed by a literal `>` that
9
+ the `[^>]` branch cannot consume, so backtracking only ever happened on
10
+ failure). The lazy loops got a disjoint branch 3 (`[^>"']`: the catch-all
11
+ no longer eats quote characters), which makes re-partitioning impossible
12
+ and stops an unterminated quote inside a tag from pairing with a distant
13
+ quote and matching across markup.
14
+
15
+ A1 behavioral pins (success cases unchanged) and A2 pins (unterminated-
16
+ quote tags are now skipped rather than mis-paired) live beside the spin
17
+ guards here. The spin guard is the same shape as the 0.41.0 test: a
18
+ failing candidate over an apostrophe-rich run must finish, not hang."""
19
+
20
+ import signal
21
+ import time
22
+ import unittest
23
+
24
+ from bindery import epub, transforms
25
+ from bindery.transforms import _START_TAG_RE
26
+
27
+
28
+ def _apostrophe_run(times: int = 2000) -> str:
29
+ return "don't can't won't it's kind of prose, isn't it " * times
30
+
31
+
32
+ class GreedyStartTagSpin(unittest.TestCase):
33
+ """_START_TAG_RE runs under repair --all on every content doc: the same
34
+ input class as the Physics fixture (an apostrophe-rich run with no `>`
35
+ after the tag opener) must fail linearly."""
36
+
37
+ def test_spin_shape_fails_fast(self):
38
+ doc = "<p class='broken " + _apostrophe_run() + "and still no closer in sight"
39
+ signal.alarm(20)
40
+ try:
41
+ t0 = time.perf_counter()
42
+ m = _START_TAG_RE.search(doc)
43
+ elapsed = time.perf_counter() - t0
44
+ finally:
45
+ signal.alarm(0)
46
+ self.assertIsNone(m)
47
+ self.assertLess(elapsed, 5.0)
48
+
49
+ def test_success_shape_is_unchanged(self):
50
+ text = "<p class='x' data-y=\"a>b\">body</p>"
51
+ m = _START_TAG_RE.search(text)
52
+ self.assertEqual(m.group(0), "<p class='x' data-y=\"a>b\">")
53
+ # the `>` inside the quoted attribute stays attribute content
54
+ self.assertNotIn("body", m.group(0))
55
+
56
+
57
+ class OpfTagMatchersSpin(unittest.TestCase):
58
+ """The OPF matchers iterate manifest/spine/guide tags: a truncated OPF
59
+ that ends inside an apostrophe-rich attribute run must fail linearly."""
60
+
61
+ SPIN_PREFIX = "<item id='id1' href='text/part1.xhtml' media-type='application/xhtml+xml' extra='"
62
+
63
+ def _assert_fast_no_match(self, regex, text):
64
+ signal.alarm(20)
65
+ try:
66
+ t0 = time.perf_counter()
67
+ m = regex.search(text)
68
+ elapsed = time.perf_counter() - t0
69
+ finally:
70
+ signal.alarm(0)
71
+ self.assertIsNone(m)
72
+ self.assertLess(elapsed, 5.0)
73
+
74
+ def test_item_tag_spin_shape(self):
75
+ self._assert_fast_no_match(
76
+ epub._ITEM_TAG_RE, self.SPIN_PREFIX + _apostrophe_run(500)
77
+ )
78
+
79
+ def test_itemref_tag_spin_shape(self):
80
+ text = "<itemref idref='id1' extra='" + _apostrophe_run(500)
81
+ self._assert_fast_no_match(epub._SPINE_ITEM_RE, text)
82
+
83
+ def test_cover_meta_spin_shape(self):
84
+ text = '<meta name="cover" content="' + _apostrophe_run(500)
85
+ self._assert_fast_no_match(epub._COVER_META_RE, text)
86
+
87
+ def test_guide_reference_spin_shape(self):
88
+ text = "<reference type='text' title='" + _apostrophe_run(500)
89
+ self._assert_fast_no_match(epub._GUIDE_REF_RE, text)
90
+
91
+ def test_success_shapes_unchanged(self):
92
+ opf = (
93
+ '<package><item id="id1" href="a.xhtml" media-type="application/xhtml+xml"/>'
94
+ "<itemref idref='id1'/>"
95
+ '<meta name="cover" content="cover-image"/>'
96
+ "<reference type='text' title='t' href='a.xhtml'/></package>"
97
+ )
98
+ item = epub._ITEM_TAG_RE.search(opf)
99
+ self.assertEqual(
100
+ item.group(0),
101
+ '<item id="id1" href="a.xhtml" media-type="application/xhtml+xml"/>',
102
+ )
103
+ self.assertIsNotNone(epub._SPINE_ITEM_RE.search(opf))
104
+ self.assertIsNotNone(epub._COVER_META_RE.search(opf))
105
+ self.assertIsNotNone(epub._GUIDE_REF_RE.search(opf))
106
+
107
+ def test_prune_dangling_edges_spin_shape(self):
108
+ # prune_dangling_edges' inline spine|item|itemref matcher over a
109
+ # truncated OPF: linear failure, no hang.
110
+ opf = '<package><spine toc="ncx"><itemref idref=\'broken ' + _apostrophe_run(
111
+ 500
112
+ )
113
+ signal.alarm(20)
114
+ try:
115
+ t0 = time.perf_counter()
116
+ epub.prune_dangling_edges(opf, {"gone"})
117
+ elapsed = time.perf_counter() - t0
118
+ finally:
119
+ signal.alarm(0)
120
+ self.assertLess(elapsed, 5.0)
121
+
122
+
123
+ class LazyMatcherDisjointBranch(unittest.TestCase):
124
+ """The lazy matchers (_VOID_RE, the img/link/a tag matchers) now use a
125
+ disjoint branch 3 ([^>"']): an unterminated quote inside a tag can no
126
+ longer pair with a distant quote, so the mis-pairing match is gone.
127
+ Every well-formed shape is byte-identical."""
128
+
129
+ def test_void_unterminated_quote_skipped(self):
130
+ # OLD: this matched, pairing the quote with a distant one and
131
+ # mis-styling markup after the tag. NEW: no match, text untouched.
132
+ text = '<br x="a> and more prose follows here'
133
+ self.assertIsNone(transforms._VOID_RE.search(text))
134
+
135
+ def test_void_wellformed_shapes_still_match(self):
136
+ for tag in ("<br/>", "<br />", '<br class="x"/>', "<hr class='x' />"):
137
+ self.assertIsNotNone(transforms._VOID_RE.search(tag), tag)
138
+
139
+ def test_img_unterminated_quote_skipped(self):
140
+ text = '<img src="a> more prose'
141
+ self.assertIsNone(transforms._IMG_TAG_RE.search(text))
142
+
143
+ def test_img_wellformed_rebuild_identical(self):
144
+ tag = '<img src="pic.png" alt="a photo" />'
145
+ out, count = transforms.add_img_alt("<html><body>" + tag + "</body></html>")
146
+ self.assertEqual(count, 0) # alt already present; tag untouched
147
+ self.assertIn(tag, out)
148
+
149
+ def test_prune_img_unterminated_quote_skipped(self):
150
+ text = '<img src="a> more prose'
151
+ self.assertIsNone(epub._PRUNE_IMG_TAG_RE.search(text))
152
+
153
+ def test_link_and_anchor_unterminated_quote_skipped(self):
154
+ self.assertIsNone(epub._LINK_TAG_RE.search('<link href="a> prose'))
155
+ self.assertIsNone(epub._ANCHOR_TAG_RE.search("<a href='a> prose"))
156
+
157
+ def test_link_and_anchor_wellformed_match(self):
158
+ self.assertIsNotNone(
159
+ epub._LINK_TAG_RE.search('<link href="a.css" rel="stylesheet"/>')
160
+ )
161
+ self.assertIsNotNone(epub._ANCHOR_TAG_RE.search('<a href="b.xhtml">text</a>'))
162
+
163
+
164
+ if __name__ == "__main__":
165
+ unittest.main()
@@ -389,6 +389,38 @@ class TestProtectedSpans(unittest.TestCase):
389
389
  out, n = unwrap_illegal_tags("<p>a<w>x</w>b<st>c</st></p>")
390
390
  self.assertEqual((out, n), ("<p>axbc</p>", 4))
391
391
 
392
+ def test_span_wrapping_block_with_gt_in_attribute_unwraps(self):
393
+ # the reason the start-tag match is quote-aware at all: the `>` of
394
+ # "a>b" is attribute content, and the span still wraps a block
395
+ text = '<p><span title="a>b"><div class="k">x</div></span>tail</p>'
396
+ out, n = unwrap_block_in_inline(text)
397
+ self.assertEqual((out, n), ('<p><div class="k">x</div>tail</p>', 1))
398
+
399
+ def test_failing_span_candidate_cannot_backtrack_explode(self):
400
+ # REGRESSION (0.41.0, the Physics fixture): the quote-aware prefix
401
+ # branches overlap ([^>] also matches quote characters), so in a
402
+ # long apostrophe-rich run with no `>` after a <span, the engine
403
+ # re-partitioned the prefix exponentially on the failing match and
404
+ # spun CPU indefinitely (an 843k-char back-matter document never
405
+ # finished). The possessive `*+` commits to the first partition and
406
+ # makes failure linear. This shape is the guard: the old pattern
407
+ # cannot finish this function call at all.
408
+ import signal
409
+ import time
410
+
411
+ run = "don't can't won't it's a>kind of prose, isn't it " * 2000
412
+ doc = "<p><span class='broken " + run + "and no closer.</p>"
413
+ signal.alarm(20) # belt: fail the test, never hang the suite
414
+ try:
415
+ t0 = time.perf_counter()
416
+ out, n = unwrap_block_in_inline(doc)
417
+ elapsed = time.perf_counter() - t0
418
+ finally:
419
+ signal.alarm(0)
420
+ self.assertEqual(n, 0)
421
+ self.assertEqual(out, doc) # nothing matched: byte-for-byte
422
+ self.assertLess(elapsed, 5.0)
423
+
392
424
  def test_illegal_tag_with_gt_in_attribute_value_is_removed_whole(self):
393
425
  # a bare [^>]* matcher ended the start tag at the `>` inside the
394
426
  # attribute value, mangled the edit, and the gate rejected the run
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.41.0"
11
+ version = "0.41.1"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes