bindery-cli 0.41.1__tar.gz → 0.43.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 (46) hide show
  1. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/.github/workflows/publish.yml +5 -6
  2. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/CLAUDE.md +2 -2
  3. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/PKG-INFO +1 -1
  4. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/patchnotes.md +14 -0
  5. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/project.done +7 -0
  6. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/pyproject.toml +1 -1
  7. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/roadmap.md +43 -22
  8. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/__init__.py +1 -1
  9. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/audit.py +83 -3
  10. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/epub.py +10 -10
  11. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/transforms.py +3 -3
  12. bindery_cli-0.43.0/tests/test_matcher_hardening.py +165 -0
  13. bindery_cli-0.43.0/tests/test_xml_hardening.py +62 -0
  14. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/.github/workflows/ci.yml +0 -0
  15. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/.gitignore +0 -0
  16. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/AGENTS.md +0 -0
  17. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/LICENSE +0 -0
  18. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/README.md +0 -0
  19. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/docs/screenshots/library-sweep.png +0 -0
  20. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/logo.svg +0 -0
  21. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/plugin/__init__.py +0 -0
  22. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/run_tests.sh +0 -0
  23. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/scripts/FastSweep.java +0 -0
  24. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/scripts/build_plugin.py +0 -0
  25. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/scripts/fast_sweep.py +0 -0
  26. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/spec.md +0 -0
  27. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/__main__.py +0 -0
  28. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/cli.py +0 -0
  29. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/library.py +0 -0
  30. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/pagination.py +0 -0
  31. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/reserialize.py +0 -0
  32. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/validate.py +0 -0
  33. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/src/bindery/watermark.py +0 -0
  34. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  35. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_audit.py +0 -0
  36. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_cli.py +0 -0
  37. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_epub.py +0 -0
  38. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_library.py +0 -0
  39. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_pagination.py +0 -0
  40. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_plugin.py +0 -0
  41. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_reserialize.py +0 -0
  42. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_transforms.py +0 -0
  43. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_validate.py +0 -0
  44. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_version.py +0 -0
  45. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/tests/test_watermark.py +0 -0
  46. {bindery_cli-0.41.1 → bindery_cli-0.43.0}/uv.lock +0 -0
@@ -73,13 +73,12 @@ 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. The checkout is depth-1 and carries no
78
- # tag objects, so the tag ref is fetched explicitly first (without
79
- # this, `git tag -l` lists nothing and the release body silently falls
80
- # back to the commit message).
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.
81
80
  - name: Fetch the tag object
82
- run: git fetch --no-tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
81
+ run: git fetch --no-tags --force origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
83
82
 
84
83
  - name: Extract the release body from the tag message
85
84
  run: git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" > release-body.md
@@ -82,11 +82,11 @@ 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. 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 loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). 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`.
89
- - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis (a damaged entry reports its own CORRUPT verdict instead of feeding emptytext) and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry; do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED). Since v0.40.0 the vir-tui console renderer loads through the module-level `_LazyUI` proxy (a plain `import vir_tui as ui` at module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module `__getattr__`, so a proxy object is the lazy shape that works).
89
+ - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis (a damaged entry reports its own CORRUPT verdict instead of feeding emptytext) and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry; do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED). Since v0.43.0 untrusted metadata XML is parsed through `_safe_xml_parse` (DOCTYPE/ENTITY declarations refused before xml.etree sees them; the stdlib shape of defusedxml's entity protection) and single entries above `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression; a refused metadata file returns the shell book. Since v0.40.0 the vir-tui console renderer loads through the module-level `_LazyUI` proxy (a plain `import vir_tui as ui` at module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module `__getattr__`, so a proxy object is the lazy shape that works).
90
90
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan). Since v0.38.0/v0.39.0 (Phase 16): `generate_container` + the `--fix-container` gateway (insert after mimetype when missing, replace in-loop when stale; constant-epoch timestamp keeps bytes deterministic), `prune_dangling_edges` (prune_missing_manifest_items returns the pruned ids and the edges that pointed at them (spine@toc, media-overlay, fallback, EPUB2 cover meta) are rewritten so pruning cannot manufacture the regression the gate rejects), and `fix_manifest_media_types` (extension map + magic-byte confirmation via a `peek` callable over the open source zip; jpg/png/gif only), and `fix_cover_meta` (the ruling's deterministic half: dangling EPUB2 cover meta re-pointed from the guide's cover reference when exactly one manifest item carries that file, removed otherwise; accepted under no_worse like the lossy strips because cover wiring is invisible to epubcheck).
91
91
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
92
92
  - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched: never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book. Since 2026-09-15 `make_backup` takes the opt-in `--backup-keep N` ring: at most N backup files per book, the author original `.bak` never deleted, newest content at the highest name; default remains an unbounded rotation.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.41.1
3
+ Version: 0.43.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,18 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.43.0 (2026-09-16)
3
+
4
+ ### The stdlib XML hardening: untrusted metadata is bounded and entity-refused, no dependency
5
+
6
+ * **`--audit` and every `load_book` caller no longer decompress blind.** Two protections, both stdlib-only (the defusedxml dependency stays declined): single entries whose declared size exceeds `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression -- a bomb entry in a tiny zip cannot be downloaded into memory -- and the untrusted metadata files (container.xml, OPF, encryption.xml) parse through `_safe_xml_parse`, which refuses DOCTYPE/ENTITY declarations before xml.etree sees them. A refused or unparseable metadata file returns the shell book (the archive verdict is the book's whole story), never a hang or a traceback.
7
+ * Pinned by `tests/test_xml_hardening.py` (entity-bomb container returns the shell book; clean XML parses; the entry cap is sane). The defusedxml dependency ask is closed as declined with the stdlib shape delivered.
8
+
9
+ ## v0.42.0 (2026-09-16)
10
+
11
+ ### The matcher audit executed: every quote-aware tag matcher hardened
12
+ 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.
13
+
14
+ 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.
15
+
2
16
  ## v0.41.1 (2026-09-15)
3
17
 
4
18
  ### The 0.41.0 CPU-spin regression: possessive bounds on the quote-aware tag matchers
@@ -4,6 +4,13 @@ Completed: 2026-09-15 (THE FINAL BLITZ, repo agent lane)
4
4
  Range: 2b9f24e (analyzer trio) through f47fc8f (ci tag-fetch fix), on main,
5
5
  pushed; CI green throughout; v0.41.0 tagged and published to PyPI.
6
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
+
7
14
  ## What shipped
8
15
 
9
16
  - The analyzer-robustness trio: spineless-EPUB verdicts, per-book try
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.41.1"
7
+ version = "0.43.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
@@ -1841,13 +1841,18 @@ four interpreter families.
1841
1841
  testing_facility library (invented titles, real dry-run output);
1842
1842
  the current PNG shows 16 real rows. Ask asked 2026-09-15,
1843
1843
  unanswered; nothing touched.
1844
- - [ ] **Stranger-facing XML hardening** (dependency-flagged): a
1845
- per-entry size cap in load_book (stdlib, bounds decompression)
1846
- plus defusedxml-style entity-expansion gating for the untrusted
1847
- XML (container.xml, OPF, encryption.xml, NCX). RECORDED REOPEN
1848
- (Brandon's ask pending; the dependency ask would violate the
1849
- stdlib-only rule and adds a vendored plugin dep): trigger is a
1850
- real 3.12/3.13 stranger audience, not a hypothetical.
1844
+ - [x] **Stranger-facing XML hardening** *(SHIPPED 2026-09-16, v0.43.0
1845
+ -- the STDLIB half only, no dependency: `_MAX_ENTRY_BYTES` (32 MB)
1846
+ checked before `_read`'s decompression so a bomb entry is refused,
1847
+ and `_safe_xml_parse` refuses DOCTYPE/ENTITY declarations in the
1848
+ untrusted metadata (container.xml, OPF, encryption.xml) before
1849
+ xml.etree sees them; a refused or unparseable metadata file
1850
+ returns the shell book, the archive being the book's whole story.
1851
+ Pinned by tests/test_xml_hardening.py. The defusedxml dependency
1852
+ itself STAYS DECLINED: the stdlib shape delivers the entity
1853
+ protection, and the vendored-plugin cost was the box's real
1854
+ objection. The full-parser-swap reopen remains Brandon-gated if a
1855
+ real 3.12/3.13 stranger audience ever materializes.)*
1851
1856
  - [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
1857
 
1853
1858
  - [x] **REGRESSION (0.41.0) FIXED in v0.41.1: the repair --all CPU
@@ -1870,14 +1875,30 @@ four interpreter families.
1870
1875
  earlier transforms move where failing candidates sit; the regex
1871
1876
  was input-sensitive, not the flags broken. Unpin 0.40.0 once
1872
1877
  0.41.1 is installed.
1873
- - [ ] **Audit the remaining quote-aware tag matchers for the same
1874
- overlap ambiguity** (follow-up from the 0.41.1 hotfix): _VOID_RE
1875
- and the OPF matchers (cover meta, guide reference,
1876
- spine/item/itemref) share the overlapping-alternation shape but
1877
- were tested against the spin shape and do not blow up (the lazy
1878
- loop and always-matching candidates behave differently;
1879
- _VOID_RE's failure mode on a quote-run with no reachable `>` is
1880
- quadratic at worst, and it can match surprisingly far across
1881
- quoted spans, pre-existing since 0.40.0). Decide per matcher:
1882
- possessive-bound for uniformity, or document why the shape is
1883
- safe there. Never a third grammar copy.
1878
+ - [x] **Audit the remaining quote-aware tag matchers for the same
1879
+ overlap ambiguity** *(EXECUTED 2026-09-16, v0.42.0 -- the full
1880
+ per-matcher census, one ruling each: the audit found the box's own
1881
+ enumeration incomplete. Six GREEDY exponential-class sites were
1882
+ unhardened, possessive-bounded now (success is byte-identical:
1883
+ the loop is always followed by a literal `>` that `[^>]` cannot
1884
+ consume): transforms.py `_START_TAG_RE` -- the true gap, not even
1885
+ named in the box, running under repair --all on every content doc;
1886
+ `_COVER_META_RE`; `_GUIDE_REF_RE`; prune_dangling_edges'
1887
+ spine|item|itemref matcher; `_ITEM_TAG_RE`; `_SPINE_ITEM_RE`.
1888
+ Five LAZY quadratic-class sites took the disjoint catch-all
1889
+ `[^>"']` instead of a blind possessive swap (a lazy `*?` ->
1890
+ `*+` is NOT byte-identical with the trailing `\s*(/?)>`
1891
+ continuation, which peels whitespace and the self-close slash):
1892
+ `_VOID_RE`, the transforms `<img>` matcher, and epub's
1893
+ link/a/img matchers -- mutually exclusive branches make any
1894
+ quantifier linear and fix the far-match wart: an unterminated
1895
+ quote inside a tag now fails to match instead of pairing across
1896
+ markup. The epub `_IMG_TAG_RE` renamed `_PRUNE_IMG_TAG_RE` (name
1897
+ collision with the transforms one). Everything else audited safe
1898
+ by construction: tempered-quote value matchers, two-branch
1899
+ quote alternations, `[^>]*` single-class prefixes (linear,
1900
+ not quote-aware -- accepted), lazy paired-delimiter spans, and
1901
+ the analyzer tokenizers. Pinned by tests/test_matcher_hardening.py:
1902
+ 15 tests (spin shapes alarm-guarded per greedy site, unterminated-
1903
+ quote skips, well-formed byte-identity, prune spin over the
1904
+ 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.1"
14
+ VERSION = "0.43.0"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -226,6 +226,34 @@ class Book:
226
226
  return self._visible
227
227
 
228
228
 
229
+ # v0.43.0 XML hardening (stdlib-only): a single EPUB component above this
230
+ # size is refused before decompression (a real book's largest component --
231
+ # one content doc, the OPF, the NCX -- is nowhere near it), and the
232
+ # metadata XML parser refuses DOCTYPE/ENTITY declarations, which is
233
+ # defusedxml's entity protection without the dependency.
234
+ _MAX_ENTRY_BYTES = 32 * 1024 * 1024
235
+
236
+ # a stand-in root for a refused metadata parse: iter() over it is empty
237
+ _ET_EMPTY = ET.Element("empty")
238
+
239
+
240
+ def _safe_xml_parse(blob: bytes | None):
241
+ """xml.etree parse for UNTRUSTED metadata files, stdlib-hardened: a
242
+ DOCTYPE or ENTITY declaration anywhere in the blob is refused before
243
+ parsing, which is defusedxml's entity protection without the
244
+ dependency. Returns None for a refused or unparseable blob; callers
245
+ treat that exactly like a missing file."""
246
+ if blob is None:
247
+ return None
248
+ head = blob[:4096].upper()
249
+ if b"<!DOCTYPE" in head or b"<!ENTITY" in head:
250
+ return None
251
+ try:
252
+ return ET.fromstring(blob)
253
+ except Exception:
254
+ return None
255
+
256
+
229
257
  def load_book(path: Path) -> Book:
230
258
  """Open an EPUB once: fully read EVERY archive entry (the CRC sweep the
231
259
  phase-1 skill used to do by hand), resolve spine + nav + declared
@@ -238,6 +266,7 @@ def load_book(path: Path) -> Book:
238
266
  nameset = set(names)
239
267
  corrupt: list[str] = []
240
268
  encrypted: list[str] = []
269
+ oversized: list[str] = []
241
270
  spine_missing = 0
242
271
  dup_entries = len(names) - len(nameset) # zip resolves last-wins
243
272
  raw: dict[str, bytes] = {}
@@ -260,6 +289,12 @@ def load_book(path: Path) -> Book:
260
289
  if name in corrupt or name in encrypted:
261
290
  return None
262
291
  try:
292
+ # v0.43.0 hardening: the declared size is checked BEFORE the
293
+ # read, so a bomb entry (a tiny zip that decompresses to
294
+ # gigabytes) is refused instead of downloaded into memory.
295
+ if z.getinfo(name).file_size > _MAX_ENTRY_BYTES:
296
+ oversized.append(name)
297
+ return None
263
298
  blob = z.read(name)
264
299
  except Exception:
265
300
  if name in encrypted_names:
@@ -279,7 +314,7 @@ def load_book(path: Path) -> Book:
279
314
  )
280
315
  if enc_blob:
281
316
  try:
282
- for ed in ET.fromstring(enc_blob).iter():
317
+ for ed in (_safe_xml_parse(enc_blob) or _ET_EMPTY).iter():
283
318
  if ed.tag.split("}")[-1] != "EncryptedData":
284
319
  continue
285
320
  algo = ""
@@ -324,12 +359,57 @@ def load_book(path: Path) -> Book:
324
359
  obfuscated=obfuscated,
325
360
  obfuscated_declared=obfuscated_declared,
326
361
  )
327
- container = ET.fromstring(container_blob)
362
+ container = _safe_xml_parse(container_blob)
363
+ if container is None:
364
+ # A container that PARSES but was refused (DOCTYPE/ENTITY
365
+ # declarations: untrusted-XML policy, v0.43.0) or is otherwise
366
+ # unparseable gets the same shell-book outcome as an absent
367
+ # one -- the archive is the book's whole story.
368
+ obfuscated = [
369
+ n
370
+ for n in sorted(encrypted_names & obfuscated_declared)
371
+ if n not in corrupt and n not in encrypted
372
+ ]
373
+ return Book(
374
+ [],
375
+ None,
376
+ "",
377
+ {},
378
+ names,
379
+ corrupt,
380
+ 0,
381
+ 0,
382
+ encrypted=encrypted,
383
+ dup_entries=dup_entries,
384
+ obfuscated=obfuscated,
385
+ obfuscated_declared=obfuscated_declared,
386
+ )
328
387
  rootfile = container.find(".//c:rootfile", CONTAINER_NS)
329
388
  opf_path = rootfile.get("full-path") if rootfile is not None else None
330
389
  if not opf_path:
331
390
  raise ValueError("container.xml has no rootfile")
332
- opf = ET.fromstring(_read(opf_path))
391
+ opf = _safe_xml_parse(_read(opf_path))
392
+ if opf is None:
393
+ # same shell-book outcome for a refused/unparseable OPF
394
+ obfuscated = [
395
+ n
396
+ for n in sorted(encrypted_names & obfuscated_declared)
397
+ if n not in corrupt and n not in encrypted
398
+ ]
399
+ return Book(
400
+ [],
401
+ None,
402
+ "",
403
+ {},
404
+ names,
405
+ corrupt,
406
+ 0,
407
+ 0,
408
+ encrypted=encrypted,
409
+ dup_entries=dup_entries,
410
+ obfuscated=obfuscated,
411
+ obfuscated_declared=obfuscated_declared,
412
+ )
333
413
  base = os.path.dirname(opf_path)
334
414
 
335
415
  manifest: dict[str, str] = {}
@@ -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,
@@ -659,21 +659,21 @@ _DROP_HREF_ATTR_RE = re.compile(r"""\s+href\s*=\s*(?:"[^"]*"|'[^']*')""", re.IGN
659
659
  # shape as transforms._VOID_RE, so a '>' inside an attribute value cannot end
660
660
  # the tag early.
661
661
  _LINK_TAG_RE = re.compile(
662
- r"""<link(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
662
+ r"""<link(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
663
663
  re.IGNORECASE | re.DOTALL,
664
664
  )
665
665
  _ANCHOR_TAG_RE = re.compile(
666
- r"""<a(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""", re.IGNORECASE | re.DOTALL
666
+ r"""<a(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""", re.IGNORECASE | re.DOTALL
667
667
  )
668
- _IMG_TAG_RE = re.compile(
669
- r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
668
+ _PRUNE_IMG_TAG_RE = re.compile(
669
+ r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
670
670
  re.IGNORECASE | re.DOTALL,
671
671
  )
672
- _ITEM_TAG_RE = re.compile(r"""<item\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""", re.IGNORECASE)
672
+ _ITEM_TAG_RE = re.compile(r"""<item\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE)
673
673
  # `<itemref` must not be swallowed by the `<item\b` matcher above (\b has no
674
674
  # boundary before the 'r'), and its own idref carries the spine order.
675
675
  _SPINE_ITEM_RE = re.compile(
676
- r"""<itemref\b(?:(?:"[^"]*"|'[^']*'|[^>])*)>""", re.IGNORECASE
676
+ r"""<itemref\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE
677
677
  )
678
678
  # `\b` is what keeps `<item\b` from matching `<itemref`: "itemref" has a word
679
679
  # character right after "item", so there is no boundary there.
@@ -999,7 +999,7 @@ def prune_missing_images(
999
999
  counts[key] = counts.get(key, 0) + 1
1000
1000
  return alt if key == "missing_imgs_unwrapped" else ""
1001
1001
 
1002
- return _IMG_TAG_RE.sub(repl, part), counts
1002
+ return _PRUNE_IMG_TAG_RE.sub(repl, part), counts
1003
1003
 
1004
1004
  total: dict[str, int] = {}
1005
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
@@ -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()
@@ -0,0 +1,62 @@
1
+ """The v0.43.0 stdlib XML hardening: untrusted metadata files are parsed
2
+ through _safe_xml_parse, which refuses DOCTYPE/ENTITY declarations (the
3
+ stdlib equivalent of defusedxml's entity protection), and single archive
4
+ entries above _MAX_ENTRY_BYTES are refused before decompression. A
5
+ refused metadata file returns the shell book -- the archive is the
6
+ book's whole story -- instead of hanging or crashing."""
7
+
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ from bindery.audit import _MAX_ENTRY_BYTES, _safe_xml_parse, load_book
13
+
14
+ BOMB_CONTAINER = (
15
+ '<?xml version="1.0"?>\n<!DOCTYPE container [<!ENTITY a "'
16
+ + "A" * 100000
17
+ + '"]>\n<container/>'
18
+ )
19
+
20
+
21
+ def _write_epub(path: Path, container: bytes, opf: bytes | None = None):
22
+ import zipfile
23
+
24
+ with zipfile.ZipFile(path, "w") as z:
25
+ z.writestr("mimetype", "application/epub+zip")
26
+ z.writestr("META-INF/container.xml", container)
27
+ if opf is not None:
28
+ z.writestr("content.opf", opf)
29
+
30
+
31
+ class SafeXmlParseTests(unittest.TestCase):
32
+ def test_doctype_is_refused(self):
33
+ self.assertIsNone(_safe_xml_parse(BOMB_CONTAINER.encode()))
34
+
35
+ def test_entity_inline_is_refused(self):
36
+ self.assertIsNone(_safe_xml_parse(b"<x>&entity;</x>"))
37
+
38
+ def test_clean_xml_parses(self):
39
+ root = _safe_xml_parse(b"<container><rootfile/></container>")
40
+ self.assertIsNotNone(root)
41
+
42
+ def test_none_blob_passes_through_as_none(self):
43
+ self.assertIsNone(_safe_xml_parse(None))
44
+
45
+
46
+ class EntityBombContainerTests(unittest.TestCase):
47
+ def test_entity_bomb_container_returns_shell_book(self):
48
+ with tempfile.TemporaryDirectory() as td:
49
+ p = Path(td) / "bomb.epub"
50
+ _write_epub(p, BOMB_CONTAINER.encode())
51
+ book = load_book(p) # must not hang, must not raise
52
+ self.assertEqual(book.spine, [])
53
+ self.assertEqual(book.corrupt, [])
54
+
55
+ def test_max_entry_bytes_is_sane(self):
56
+ # a real book's largest component is nowhere near this; the cap
57
+ # exists to refuse bomb entries before decompression.
58
+ self.assertGreaterEqual(_MAX_ENTRY_BYTES, 8 * 1024 * 1024)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes