bindery-cli 0.42.0__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.
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/CLAUDE.md +1 -1
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/PKG-INFO +1 -1
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/patchnotes.md +7 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/pyproject.toml +1 -1
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/roadmap.md +12 -7
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/audit.py +83 -3
- bindery_cli-0.43.0/tests/test_xml_hardening.py +62 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/.gitignore +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/AGENTS.md +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/LICENSE +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/README.md +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/logo.svg +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/project.done +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/run_tests.sh +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/spec.md +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/cli.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/testing_facility/books_to_fix/.gitkeep +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_cli.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_library.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_version.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.42.0 → bindery_cli-0.43.0}/uv.lock +0 -0
|
@@ -86,7 +86,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
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.
|
|
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,11 @@
|
|
|
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
|
+
|
|
2
9
|
## v0.42.0 (2026-09-16)
|
|
3
10
|
|
|
4
11
|
### The matcher audit executed: every quote-aware tag matcher hardened
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
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 = [
|
|
@@ -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
|
-
- [
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
|
@@ -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.
|
|
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
|
|
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 =
|
|
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 =
|
|
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] = {}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|