python-hwpx 6.0.2__tar.gz → 6.0.3__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.
- {python_hwpx-6.0.2/src/python_hwpx.egg-info → python_hwpx-6.0.3}/PKG-INFO +1 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/pyproject.toml +1 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/body_patch.py +10 -6
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/ingest/hwpx_converter.py +13 -2
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/layout/lint.py +3 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/mutation_report.py +3 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/opc/package.py +2 -2
- python_hwpx-6.0.3/src/hwpx/opc/security.py +240 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/patch.py +11 -2
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/save_pipeline.py +3 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/table_patch.py +11 -5
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/archive_cli.py +29 -2
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/exporter.py +2 -2
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/idempotence.py +3 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/ir_equality.py +3 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/layout_preview.py +2 -2
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/package_validator.py +8 -6
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/repair.py +10 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/text_extractor.py +11 -4
- {python_hwpx-6.0.2 → python_hwpx-6.0.3/src/python_hwpx.egg-info}/PKG-INFO +1 -1
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_opc_package.py +259 -1
- python_hwpx-6.0.2/src/hwpx/opc/security.py +0 -134
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/LICENSE +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/NOTICE +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/README.md +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/setup.cfg +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/_legacy.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/_resolve.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/_units.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/fields.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/headings.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/layout.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/media.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/memos.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/_base.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/fields.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/media.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/notes.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/page.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/parts.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/refs.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/shapes.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/styles.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/tables.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/text.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/tracking.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/persistence.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/shapes.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/tracked.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/capabilities.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/known-traps.md +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/mutation-semantics.md +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/recipes-traversal.md +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/support-matrix.md +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/document.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/authoring.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/eqedit.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/mathml.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/render.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/tokens.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/errors.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/experimental.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/apply.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/engine.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/measure.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/policy.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/report.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/ingest/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/ingest/base.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/layout/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/layout/report.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/model.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/binary_item.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/checkbox.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/form_field.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/results.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/tracked.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/opc/relationships.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/opc/xml_utils.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/_document_impl.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/_document_primitives.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/body.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/canonical_defaults.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/color.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/document.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/document_parts.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/header_part.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/memo.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/namespaces.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/numbering.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/objects.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/paragraph.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/run.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/section.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/section_format.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/section_story.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/simple_parts.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/table.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/package.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_execute.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_model.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_schema.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_validate.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/py.typed +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/ledger.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/policy.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/rendering.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/report.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/templates.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/doc_diff.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/document_viewer.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/generic_inventory.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/id_integrity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/mail_merge.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/markdown_export.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/object_finder.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/package_reconcile.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/page_guard.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/read_fidelity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/recover.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/redline.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/report_utils.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/roundtrip_diff.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/table_cleanup.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/table_navigation.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/template_analyzer.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/text_extract_cli.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/toc_author.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/toc_fidelity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/SOURCES.txt +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_add_control.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_add_heading.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_adoption_metrics_snapshot.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_authoring_defaults.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_authoring_quality_corpus.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_blind_eval_fixture.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_body_patch.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_byte_patch_identity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_capabilities_surface.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_cell_line_spacing.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_chart_authoring.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_check_box_authoring.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_comment_node_robustness.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_contract_docs_sync.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_corpus_read_fidelity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_coverage_ledger.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_coverage_promotion.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_coverage_targets.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_deviations_registry.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_doc_diff.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_context_manager.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_facade_surface.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_formatting.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_ops_runtime_boundary.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_save_api.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_viewer.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_documentation_code_fences.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_edit_plan.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_engine_surface_6_0.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_equation_authoring.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_equation_converter.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_equation_render.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_error_contract.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_error_messages.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_evalplan_core_primitives.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_existing_document_format_editing.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_field_authoring.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fields.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fill_core_primitives.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fit.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fit_integration.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_fuzz_catalog_derive_expected.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_fuzz_loop.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_fuzz_regressions.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_gap_closure_tools.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_golden_api.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_hp_tab_support.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_hwpxlib_corpus_read.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_id_generator_range.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_image_object_workflow.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ingest.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_inline_models.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_integration_roundtrip.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_layout_cache_scope.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_layout_lint.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_layout_preview.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_library_boundary_closure.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_llms_txt.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_mail_merge_xlsx.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_markdown_export.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_memo_and_style_editing.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_moved_surface_hints.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_mutation_report.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_namespace_handling.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_namespace_hygiene.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_new_features.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_note_authoring_contract.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_fields.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_media.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_notes.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_page.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_refs.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_shapes.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_tables.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_tracking.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_open_safety_corpus.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_oxml_modularization.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_packaging_license_metadata.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_packaging_py_typed.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_paragraph_keep_together.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_paragraph_section_management.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_product_boundary.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_public_artifact_hygiene.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_public_runtime_boundary.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_read_fidelity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_recover_broken_zip.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_redline_authoring.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_redline_verify.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_release_workflow_safety.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_repair_repack.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_report_utils.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_repr_snapshots.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_return_contract.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_roundtrip_fidelity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_row_heights.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_save_pipeline.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_section_headers.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_section_properties_real_roundtrip.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_set_paragraph_format_keep.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_shape_geometry_contract.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_skeleton_template_ids.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_split_cell.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_split_merged_cell.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_stable_surface.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_style_resolution.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_cleanup.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_navigation.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_patch.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_patch_dryrun.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_patch_m105.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_template_analyzer_enrichment.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_text_extractor_annotations.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_toc_author.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_toc_fidelity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_validation_severity.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_validator_comment_nodes.py +0 -0
- {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_version_metadata.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-hwpx"
|
|
7
|
-
version = "6.0.
|
|
7
|
+
version = "6.0.3"
|
|
8
8
|
description = "한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -35,6 +35,7 @@ from dataclasses import dataclass
|
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
from typing import Any, Mapping, Sequence
|
|
37
37
|
|
|
38
|
+
from .opc.security import guard_zip_file, read_member
|
|
38
39
|
from .mutation_report import MutationReport, project_byte_splice
|
|
39
40
|
from .patch import (
|
|
40
41
|
_finalize,
|
|
@@ -440,10 +441,11 @@ def recolor_runs_by_color(
|
|
|
440
441
|
import io, zipfile
|
|
441
442
|
|
|
442
443
|
with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
|
|
444
|
+
guard_zip_file(z)
|
|
443
445
|
names = z.namelist()
|
|
444
446
|
header_name = next((n for n in names if n.endswith("header.xml")), None)
|
|
445
|
-
header_xml = z
|
|
446
|
-
sections = {n: z
|
|
447
|
+
header_xml = read_member(z, header_name).decode("utf-8") if header_name else ""
|
|
448
|
+
sections = {n: read_member(z, n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
|
|
447
449
|
|
|
448
450
|
ids = set()
|
|
449
451
|
for cm in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?>", header_xml):
|
|
@@ -511,10 +513,11 @@ def strip_runs_by_color(
|
|
|
511
513
|
|
|
512
514
|
targets = {h.upper() for h in hex_colors}
|
|
513
515
|
with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
|
|
516
|
+
guard_zip_file(z)
|
|
514
517
|
names = z.namelist()
|
|
515
518
|
header_name = next((n for n in names if n.endswith("header.xml")), None)
|
|
516
|
-
header_xml = z
|
|
517
|
-
sections = {n: z
|
|
519
|
+
header_xml = read_member(z, header_name).decode("utf-8") if header_name else ""
|
|
520
|
+
sections = {n: read_member(z, n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
|
|
518
521
|
|
|
519
522
|
# 계열 매칭(잔존 게이트와 정렬): 대상 색의 _color_family에 드는 모든 charPr.
|
|
520
523
|
from .oxml.color import color_family
|
|
@@ -589,13 +592,14 @@ def apply_body_ops(
|
|
|
589
592
|
|
|
590
593
|
header_part: str | None = None
|
|
591
594
|
with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
|
|
595
|
+
guard_zip_file(z)
|
|
592
596
|
sections = {
|
|
593
|
-
n: z
|
|
597
|
+
n: read_member(z, n).decode("utf-8")
|
|
594
598
|
for n in z.namelist()
|
|
595
599
|
if re.search(r"section\d+\.xml$", n)
|
|
596
600
|
}
|
|
597
601
|
header_part = next((n for n in z.namelist() if n.endswith("header.xml")), None)
|
|
598
|
-
header_xml = z
|
|
602
|
+
header_xml = read_member(z, header_part).decode("utf-8") if header_part else ""
|
|
599
603
|
|
|
600
604
|
ctx: dict[str, Any] = {"header": header_xml, "header_changed": False, "charpr_cache": {}}
|
|
601
605
|
skipped: list[dict[str, Any]] = []
|
|
@@ -10,6 +10,12 @@ from zipfile import BadZipFile, ZipFile
|
|
|
10
10
|
from hwpx.document import HwpxDocument
|
|
11
11
|
|
|
12
12
|
from .base import DocumentIngestResult, DocumentSourceInfo
|
|
13
|
+
from ..opc.security import (
|
|
14
|
+
MAX_ZIP_MIMETYPE_BYTES as _MAX_MIMETYPE_BYTES,
|
|
15
|
+
HwpxSecurityError,
|
|
16
|
+
guard_zip_file,
|
|
17
|
+
read_member,
|
|
18
|
+
)
|
|
13
19
|
|
|
14
20
|
|
|
15
21
|
class HwpxMarkdownConverter:
|
|
@@ -70,16 +76,21 @@ def _looks_like_hwpx_package(file_stream: BinaryIO) -> bool:
|
|
|
70
76
|
cur_pos = file_stream.tell()
|
|
71
77
|
try:
|
|
72
78
|
with ZipFile(file_stream) as archive:
|
|
79
|
+
guard_zip_file(archive)
|
|
73
80
|
names = set(archive.namelist())
|
|
74
81
|
if "mimetype" in names:
|
|
75
82
|
try:
|
|
76
|
-
mimetype =
|
|
83
|
+
mimetype = (
|
|
84
|
+
read_member(archive, "mimetype", limit=_MAX_MIMETYPE_BYTES)
|
|
85
|
+
.decode("utf-8", "replace")
|
|
86
|
+
.strip()
|
|
87
|
+
)
|
|
77
88
|
if "hwp" in mimetype.lower() or "hwpx" in mimetype.lower():
|
|
78
89
|
return True
|
|
79
90
|
except Exception:
|
|
80
91
|
pass
|
|
81
92
|
return any(name.startswith("Contents/section") and name.endswith(".xml") for name in names)
|
|
82
|
-
except (BadZipFile, OSError):
|
|
93
|
+
except (BadZipFile, OSError, HwpxSecurityError):
|
|
83
94
|
return False
|
|
84
95
|
finally:
|
|
85
96
|
file_stream.seek(cur_pos)
|
|
@@ -35,6 +35,7 @@ from hwpx.tools.package_validator import (
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
from .report import LayoutFinding, LayoutLintReport
|
|
38
|
+
from ..opc.security import guard_zip_file, read_member
|
|
38
39
|
|
|
39
40
|
if TYPE_CHECKING:
|
|
40
41
|
from hwpx.quality.ledger import DirtyLayoutLedger
|
|
@@ -173,11 +174,12 @@ def _section_roots(data: bytes) -> list[tuple[str, ET.Element]]:
|
|
|
173
174
|
roots: list[tuple[str, ET.Element]] = []
|
|
174
175
|
try:
|
|
175
176
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
177
|
+
guard_zip_file(archive)
|
|
176
178
|
for info in archive.infolist():
|
|
177
179
|
if info.is_dir() or not is_section_part_name(info.filename):
|
|
178
180
|
continue
|
|
179
181
|
try:
|
|
180
|
-
roots.append((info.filename, ET.fromstring(archive
|
|
182
|
+
roots.append((info.filename, ET.fromstring(read_member(archive, info))))
|
|
181
183
|
except ET.ParseError:
|
|
182
184
|
# Malformed XML is the pipeline's well-formedness floor, not ours.
|
|
183
185
|
continue
|
|
@@ -19,6 +19,7 @@ import zipfile
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from dataclasses import dataclass, field
|
|
21
21
|
from io import BytesIO
|
|
22
|
+
from .opc.security import guard_zip_file, read_member
|
|
22
23
|
from typing import Any, Literal, Mapping, Sequence
|
|
23
24
|
from zipfile import ZipInfo
|
|
24
25
|
|
|
@@ -58,8 +59,9 @@ def read_archive_members(data: bytes) -> dict[str, bytes]:
|
|
|
58
59
|
"""Return the uncompressed content of every non-directory member of *data*."""
|
|
59
60
|
|
|
60
61
|
with zipfile.ZipFile(BytesIO(data)) as archive:
|
|
62
|
+
guard_zip_file(archive)
|
|
61
63
|
return {
|
|
62
|
-
info.filename: archive
|
|
64
|
+
info.filename: read_member(archive, info)
|
|
63
65
|
for info in archive.infolist()
|
|
64
66
|
if not info.is_dir()
|
|
65
67
|
}
|
|
@@ -24,7 +24,7 @@ from .relationships import (
|
|
|
24
24
|
parse_container_rootfiles,
|
|
25
25
|
parse_manifest_relationships,
|
|
26
26
|
)
|
|
27
|
-
from .security import guard_zip_file
|
|
27
|
+
from .security import guard_zip_file, read_zip_members
|
|
28
28
|
from .xml_utils import (
|
|
29
29
|
extract_xml_declaration,
|
|
30
30
|
iter_declared_namespaces,
|
|
@@ -419,7 +419,7 @@ class HwpxPackage:
|
|
|
419
419
|
with ZipFile(stream, "r") as zf:
|
|
420
420
|
guard_zip_file(zf)
|
|
421
421
|
infos = [info for info in zf.infolist() if not info.is_dir()]
|
|
422
|
-
files =
|
|
422
|
+
files = read_zip_members(zf)
|
|
423
423
|
zip_infos = {info.filename: info for info in infos}
|
|
424
424
|
zip_order = [info.filename for info in infos]
|
|
425
425
|
except BadZipFile as exc:
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Input hardening helpers for HWPX OPC/XML readers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import PurePosixPath, PureWindowsPath
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
from xml.etree import ElementTree as ET
|
|
10
|
+
from zipfile import ZIP_DEFLATED as _ZIP_DEFLATED
|
|
11
|
+
from zipfile import ZIP_STORED as _ZIP_STORED
|
|
12
|
+
from zipfile import ZipFile, ZipInfo
|
|
13
|
+
|
|
14
|
+
MAX_XML_BYTES = 64 * 1024 * 1024
|
|
15
|
+
MAX_XML_DEPTH = 256
|
|
16
|
+
MAX_ZIP_ENTRIES = 4096
|
|
17
|
+
MAX_ZIP_MEMBER_BYTES = 128 * 1024 * 1024
|
|
18
|
+
MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 512 * 1024 * 1024
|
|
19
|
+
MAX_ZIP_COMPRESSION_RATIO = 1000.0
|
|
20
|
+
MAX_ZIP_READ_CHUNK = 64 * 1024
|
|
21
|
+
# `mimetype` is a short fixed string in every real package; reading it during
|
|
22
|
+
# format sniffing must not be able to cost the full per-member allowance.
|
|
23
|
+
MAX_ZIP_MIMETYPE_BYTES = 4 * 1024
|
|
24
|
+
# Container/manifest/preview parts are small by construction; reading them
|
|
25
|
+
# during discovery should not cost the generic per-member allowance either.
|
|
26
|
+
MAX_ZIP_SMALL_PART_BYTES = 4 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
# The only methods HWPX uses, and the only ones CPython decompresses under a
|
|
29
|
+
# bound: ``ZipExtFile._read1`` passes ``max_length`` to zlib for ZIP_DEFLATED but
|
|
30
|
+
# calls ``decompress(data)`` with no limit for every other method, so a bzip2 or
|
|
31
|
+
# lzma member inflates in full before the declared size truncates the result.
|
|
32
|
+
# Chunked reading cannot bound those, so they are refused outright.
|
|
33
|
+
_ALLOWED_METHODS = (_ZIP_STORED, _ZIP_DEFLATED)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HwpxSecurityError(ValueError):
|
|
37
|
+
"""Raised when an HWPX input exceeds safe parsing limits."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ZipGuardLimits:
|
|
42
|
+
max_entries: int = MAX_ZIP_ENTRIES
|
|
43
|
+
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES
|
|
44
|
+
max_total_uncompressed_bytes: int = MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES
|
|
45
|
+
max_compression_ratio: float = MAX_ZIP_COMPRESSION_RATIO
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _iter_file_infos(zf: ZipFile) -> list[ZipInfo]:
|
|
49
|
+
return [info for info in zf.infolist() if not info.is_dir()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _guard_zip_name(name: str) -> None:
|
|
53
|
+
normalized = name.replace("\\", "/")
|
|
54
|
+
if PurePosixPath(normalized).is_absolute() or ".." in PurePosixPath(normalized).parts:
|
|
55
|
+
raise HwpxSecurityError(f"unsafe ZIP member path: {name!r}")
|
|
56
|
+
# ``PurePosixPath`` reads ``C:/x`` as a relative path, but joining it onto an
|
|
57
|
+
# output directory on Windows discards that directory entirely.
|
|
58
|
+
windows = PureWindowsPath(normalized)
|
|
59
|
+
if windows.drive or windows.is_absolute():
|
|
60
|
+
raise HwpxSecurityError(f"unsafe ZIP member path: {name!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def guard_zip_file(
|
|
64
|
+
zf: ZipFile,
|
|
65
|
+
*,
|
|
66
|
+
limits: ZipGuardLimits | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Validate ZIP metadata before reading member payloads."""
|
|
69
|
+
|
|
70
|
+
active_limits = limits or ZipGuardLimits()
|
|
71
|
+
infos = _iter_file_infos(zf)
|
|
72
|
+
if len(infos) > active_limits.max_entries:
|
|
73
|
+
raise HwpxSecurityError(
|
|
74
|
+
"ZIP archive has too many entries: "
|
|
75
|
+
f"{len(infos)} > {active_limits.max_entries}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# ``is_dir()`` only tests for a trailing slash in the name, so a member can
|
|
79
|
+
# opt out of the size accounting below just by calling itself a directory
|
|
80
|
+
# while still holding a payload that ``ZipFile.open()`` will read. Check the
|
|
81
|
+
# name of every entry, and require the ones excluded here to be empty.
|
|
82
|
+
for info in zf.infolist():
|
|
83
|
+
_guard_zip_name(info.filename)
|
|
84
|
+
if info.is_dir() and (info.file_size or info.compress_size):
|
|
85
|
+
raise HwpxSecurityError(
|
|
86
|
+
"ZIP directory entry carries data: "
|
|
87
|
+
f"{info.filename}={info.file_size}/{info.compress_size}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
total = 0
|
|
91
|
+
for info in infos:
|
|
92
|
+
if info.file_size > active_limits.max_member_bytes:
|
|
93
|
+
raise HwpxSecurityError(
|
|
94
|
+
"ZIP member exceeds uncompressed size limit: "
|
|
95
|
+
f"{info.filename}={info.file_size} > {active_limits.max_member_bytes}"
|
|
96
|
+
)
|
|
97
|
+
total += info.file_size
|
|
98
|
+
if total > active_limits.max_total_uncompressed_bytes:
|
|
99
|
+
raise HwpxSecurityError(
|
|
100
|
+
"ZIP archive exceeds total uncompressed size limit: "
|
|
101
|
+
f"{total} > {active_limits.max_total_uncompressed_bytes}"
|
|
102
|
+
)
|
|
103
|
+
if info.compress_type not in _ALLOWED_METHODS:
|
|
104
|
+
raise HwpxSecurityError(
|
|
105
|
+
"ZIP member uses an unsupported compression method: "
|
|
106
|
+
f"{info.filename}={info.compress_type}"
|
|
107
|
+
)
|
|
108
|
+
# Must run before the ``file_size <= 0`` skip below: a member declaring 0
|
|
109
|
+
# would otherwise bypass every remaining check.
|
|
110
|
+
if info.compress_size > info.file_size + info.file_size // 1000 + 64:
|
|
111
|
+
raise HwpxSecurityError(
|
|
112
|
+
"ZIP member declares less data than it stores: "
|
|
113
|
+
f"{info.filename}={info.file_size} < {info.compress_size}"
|
|
114
|
+
)
|
|
115
|
+
if info.file_size <= 0:
|
|
116
|
+
continue
|
|
117
|
+
if info.compress_size <= 0:
|
|
118
|
+
raise HwpxSecurityError(f"ZIP member has invalid compressed size: {info.filename}")
|
|
119
|
+
ratio = info.file_size / info.compress_size
|
|
120
|
+
if ratio > active_limits.max_compression_ratio:
|
|
121
|
+
raise HwpxSecurityError(
|
|
122
|
+
"ZIP member compression ratio exceeds limit: "
|
|
123
|
+
f"{info.filename}={ratio:.1f} > {active_limits.max_compression_ratio:.1f}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_member(
|
|
128
|
+
zf: ZipFile,
|
|
129
|
+
member: str | ZipInfo,
|
|
130
|
+
*,
|
|
131
|
+
limit: int = MAX_ZIP_MEMBER_BYTES,
|
|
132
|
+
) -> bytes:
|
|
133
|
+
"""Read one ZIP member without trusting the size it declares.
|
|
134
|
+
|
|
135
|
+
``ZipFile.read()`` calls ``ZipExtFile.read()`` with no argument, which hands
|
|
136
|
+
zlib a 2 GiB ``max_length`` and inflates the whole stream before truncating
|
|
137
|
+
the result to the declared size. Reading in fixed chunks and counting what
|
|
138
|
+
actually arrives keeps the allocation bounded no matter what the central
|
|
139
|
+
directory claims.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
name = member.filename if isinstance(member, ZipInfo) else member
|
|
143
|
+
chunks: list[bytes] = []
|
|
144
|
+
total = 0
|
|
145
|
+
with zf.open(member) as handle:
|
|
146
|
+
while True:
|
|
147
|
+
block = handle.read(MAX_ZIP_READ_CHUNK)
|
|
148
|
+
if not block:
|
|
149
|
+
break
|
|
150
|
+
total += len(block)
|
|
151
|
+
if total > limit:
|
|
152
|
+
raise HwpxSecurityError(
|
|
153
|
+
"ZIP member exceeds uncompressed size limit: "
|
|
154
|
+
f"{name} > {limit}"
|
|
155
|
+
)
|
|
156
|
+
chunks.append(block)
|
|
157
|
+
return b"".join(chunks)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def read_zip_members(
|
|
161
|
+
zf: ZipFile,
|
|
162
|
+
*,
|
|
163
|
+
limits: ZipGuardLimits | None = None,
|
|
164
|
+
names: Iterable[str] | None = None,
|
|
165
|
+
) -> dict[str, bytes]:
|
|
166
|
+
"""Read members with per-member and cumulative bounds on what is produced.
|
|
167
|
+
|
|
168
|
+
``names`` restricts the read to those members; the cumulative budget still
|
|
169
|
+
applies across everything read.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
active_limits = limits or ZipGuardLimits()
|
|
173
|
+
wanted = None if names is None else set(names)
|
|
174
|
+
payloads: dict[str, bytes] = {}
|
|
175
|
+
total = 0
|
|
176
|
+
for info in _iter_file_infos(zf):
|
|
177
|
+
if wanted is not None and info.filename not in wanted:
|
|
178
|
+
continue
|
|
179
|
+
data = read_member(zf, info, limit=active_limits.max_member_bytes)
|
|
180
|
+
total += len(data)
|
|
181
|
+
if total > active_limits.max_total_uncompressed_bytes:
|
|
182
|
+
raise HwpxSecurityError(
|
|
183
|
+
"ZIP archive exceeds total uncompressed size limit: "
|
|
184
|
+
f"{total} > {active_limits.max_total_uncompressed_bytes}"
|
|
185
|
+
)
|
|
186
|
+
payloads[info.filename] = data
|
|
187
|
+
return payloads
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def guard_xml_bytes(
|
|
191
|
+
payload: bytes,
|
|
192
|
+
*,
|
|
193
|
+
part_name: str = "XML payload",
|
|
194
|
+
max_xml_bytes: int = MAX_XML_BYTES,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Reject XML payloads that should never be parsed from an HWPX package."""
|
|
197
|
+
|
|
198
|
+
if len(payload) > max_xml_bytes:
|
|
199
|
+
raise HwpxSecurityError(
|
|
200
|
+
f"{part_name} exceeds XML size limit: {len(payload)} > {max_xml_bytes}"
|
|
201
|
+
)
|
|
202
|
+
lowered = payload[:4096].lower()
|
|
203
|
+
if b"<!doctype" in lowered or b"<!entity" in lowered:
|
|
204
|
+
raise HwpxSecurityError(f"{part_name} contains a disallowed DTD/entity declaration")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def guard_xml_depth(
|
|
208
|
+
root: object,
|
|
209
|
+
*,
|
|
210
|
+
part_name: str = "XML payload",
|
|
211
|
+
max_depth: int = MAX_XML_DEPTH,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Reject extremely deep XML trees after parsing."""
|
|
214
|
+
|
|
215
|
+
stack: list[tuple[object, int]] = [(root, 1)]
|
|
216
|
+
while stack:
|
|
217
|
+
element, depth = stack.pop()
|
|
218
|
+
if depth > max_depth:
|
|
219
|
+
raise HwpxSecurityError(
|
|
220
|
+
f"{part_name} exceeds XML depth limit: {depth} > {max_depth}"
|
|
221
|
+
)
|
|
222
|
+
children: Iterable[object] = list(element) # type: ignore[arg-type]
|
|
223
|
+
for child in children:
|
|
224
|
+
stack.append((child, depth + 1))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_xml_stdlib(
|
|
228
|
+
payload: bytes,
|
|
229
|
+
*,
|
|
230
|
+
part_name: str = "XML payload",
|
|
231
|
+
) -> ET.Element:
|
|
232
|
+
"""Parse XML with stdlib ElementTree after applying HWPX safety guards."""
|
|
233
|
+
|
|
234
|
+
guard_xml_bytes(payload, part_name=part_name)
|
|
235
|
+
try:
|
|
236
|
+
root = ET.fromstring(payload)
|
|
237
|
+
except ET.ParseError as exc:
|
|
238
|
+
raise ValueError(f"malformed XML: {exc}") from exc
|
|
239
|
+
guard_xml_depth(root, part_name=part_name)
|
|
240
|
+
return root
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from typing import Any, Mapping, Sequence
|
|
13
13
|
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
|
|
14
14
|
|
|
15
|
+
from .opc.security import guard_zip_file, read_member, read_zip_members
|
|
15
16
|
from .mutation_report import MutationReport, project_byte_splice, visual_value_from_status
|
|
16
17
|
from .quality import QualityPolicy, SavePipeline
|
|
17
18
|
from .quality.report import VisualCompleteReport
|
|
@@ -199,6 +200,10 @@ def paragraph_patch(
|
|
|
199
200
|
source_bytes = _read_source_bytes(source)
|
|
200
201
|
normalized_patches = tuple(_normalize_patch(item) for item in patches)
|
|
201
202
|
if not normalized_patches:
|
|
203
|
+
# The early return still hands the source to the save pipeline, so it has
|
|
204
|
+
# to clear the same limits as the patching path below.
|
|
205
|
+
with ZipFile(io.BytesIO(source_bytes), "r") as archive:
|
|
206
|
+
guard_zip_file(archive)
|
|
202
207
|
open_safety, visual_complete = _finalize(source_bytes, output_path, source=source)
|
|
203
208
|
return BytePreservingPatchResult(
|
|
204
209
|
data=source_bytes,
|
|
@@ -212,7 +217,8 @@ def paragraph_patch(
|
|
|
212
217
|
)
|
|
213
218
|
|
|
214
219
|
with ZipFile(io.BytesIO(source_bytes), "r") as archive:
|
|
215
|
-
|
|
220
|
+
guard_zip_file(archive)
|
|
221
|
+
parts = read_zip_members(archive)
|
|
216
222
|
|
|
217
223
|
changed_parts: dict[str, bytes] = {}
|
|
218
224
|
applied: list[PatchApplied] = []
|
|
@@ -481,9 +487,12 @@ def _apply_edits(payload: bytes, edits: Sequence[tuple[int, int, bytes]]) -> byt
|
|
|
481
487
|
def _rewrite_zip_entries(source: bytes, replacements: Mapping[str, bytes]) -> bytes:
|
|
482
488
|
buffer = io.BytesIO()
|
|
483
489
|
with ZipFile(io.BytesIO(source), "r") as src:
|
|
490
|
+
# Also reached directly by the public rewrite_package_parts(), so the
|
|
491
|
+
# entry-count, total-size and ratio limits have to be applied here too.
|
|
492
|
+
guard_zip_file(src)
|
|
484
493
|
with ZipFile(buffer, "w") as dst:
|
|
485
494
|
for info in src.infolist():
|
|
486
|
-
payload = replacements.get(info.filename, src
|
|
495
|
+
payload = replacements.get(info.filename, read_member(src, info))
|
|
487
496
|
dst.writestr(info, payload)
|
|
488
497
|
return buffer.getvalue()
|
|
489
498
|
|
|
@@ -48,6 +48,7 @@ from .report import (
|
|
|
48
48
|
VisualCompleteReport,
|
|
49
49
|
VisualCompleteStatus,
|
|
50
50
|
)
|
|
51
|
+
from ..opc.security import guard_zip_file, parse_xml_stdlib, read_member
|
|
51
52
|
|
|
52
53
|
PublishMode = Literal["on_pass", "always", "never"]
|
|
53
54
|
|
|
@@ -277,12 +278,13 @@ class SavePipeline:
|
|
|
277
278
|
|
|
278
279
|
try:
|
|
279
280
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
281
|
+
guard_zip_file(archive)
|
|
280
282
|
names = [info.filename for info in archive.infolist() if not info.is_dir()]
|
|
281
283
|
for name in names:
|
|
282
284
|
base = os.path.basename(name)
|
|
283
285
|
if name.endswith(_XML_SUFFIXES) or base in _XML_NAMES:
|
|
284
286
|
try:
|
|
285
|
-
|
|
287
|
+
parse_xml_stdlib(read_member(archive, name), part_name=name)
|
|
286
288
|
except ET.ParseError as exc:
|
|
287
289
|
errors.append(
|
|
288
290
|
QualityError(
|
|
@@ -30,6 +30,7 @@ from pathlib import Path
|
|
|
30
30
|
from typing import Any, Iterable, Mapping, Sequence
|
|
31
31
|
|
|
32
32
|
from .errors import HwpxError
|
|
33
|
+
from .opc.security import guard_zip_file, read_member, read_zip_members
|
|
33
34
|
from .mutation_report import MutationReport, project_byte_splice
|
|
34
35
|
from .patch import (
|
|
35
36
|
_apply_edits,
|
|
@@ -585,8 +586,9 @@ def resolve_cell_target(
|
|
|
585
586
|
import zipfile
|
|
586
587
|
|
|
587
588
|
with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as archive:
|
|
589
|
+
guard_zip_file(archive)
|
|
588
590
|
parts = {
|
|
589
|
-
info.filename: archive
|
|
591
|
+
info.filename: read_member(archive, info)
|
|
590
592
|
for info in archive.infolist()
|
|
591
593
|
if not info.is_dir()
|
|
592
594
|
}
|
|
@@ -661,7 +663,8 @@ def fill_cells(
|
|
|
661
663
|
import io
|
|
662
664
|
import zipfile
|
|
663
665
|
with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as zf:
|
|
664
|
-
|
|
666
|
+
guard_zip_file(zf)
|
|
667
|
+
parts = read_zip_members(zf)
|
|
665
668
|
|
|
666
669
|
# FR-002: resolve table/cell anchors to concrete (table_index,row,col) first.
|
|
667
670
|
resolved_cells, anchor_skips = _resolve_anchor_cells(parts, cells)
|
|
@@ -1376,7 +1379,8 @@ def _apply_cell_line_spacing(
|
|
|
1376
1379
|
import io
|
|
1377
1380
|
import zipfile
|
|
1378
1381
|
with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as zf:
|
|
1379
|
-
|
|
1382
|
+
guard_zip_file(zf)
|
|
1383
|
+
parts = read_zip_members(zf)
|
|
1380
1384
|
header_name = _header_part_name(parts)
|
|
1381
1385
|
header = parts.get(header_name)
|
|
1382
1386
|
transcript: list[dict[str, Any]] = []
|
|
@@ -1482,7 +1486,8 @@ def _sections(data: bytes) -> dict[str, bytes]:
|
|
|
1482
1486
|
import io
|
|
1483
1487
|
import zipfile
|
|
1484
1488
|
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
|
1485
|
-
|
|
1489
|
+
guard_zip_file(z)
|
|
1490
|
+
return {n: read_member(z, n) for n in z.namelist() if re.search(r"section\d+\.xml$", n)}
|
|
1486
1491
|
|
|
1487
1492
|
|
|
1488
1493
|
def _table_dims(table: str | bytes) -> str:
|
|
@@ -1795,11 +1800,12 @@ def strip_trailing_table_captions(
|
|
|
1795
1800
|
import zipfile
|
|
1796
1801
|
|
|
1797
1802
|
with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as archive:
|
|
1803
|
+
guard_zip_file(archive)
|
|
1798
1804
|
section_names = [
|
|
1799
1805
|
info.filename for info in archive.infolist()
|
|
1800
1806
|
if not info.is_dir() and re.search(r"section\d+\.xml$", info.filename)
|
|
1801
1807
|
]
|
|
1802
|
-
sections = {name: archive
|
|
1808
|
+
sections = {name: read_member(archive, name).decode("utf-8") for name in section_names}
|
|
1803
1809
|
|
|
1804
1810
|
applied: list[CellApplied] = []
|
|
1805
1811
|
changed_parts: dict[str, bytes] = {}
|
|
@@ -17,6 +17,7 @@ from lxml import etree # type: ignore[reportAttributeAccessIssue]
|
|
|
17
17
|
from ..opc.relationships import is_header_part_name, is_section_part_name
|
|
18
18
|
from ..oxml.namespaces import HWPML_COMPAT_ROOT_NAMESPACES
|
|
19
19
|
from .package_validator import validate_editor_open_safety, validate_package
|
|
20
|
+
from ..opc.security import HwpxSecurityError, guard_xml_bytes, guard_xml_depth, guard_zip_file, read_member
|
|
20
21
|
|
|
21
22
|
_XML_SUFFIXES = (".xml", ".hpf")
|
|
22
23
|
_PACK_METADATA_NAME = ".hwpx-pack-metadata.json"
|
|
@@ -81,18 +82,43 @@ def _prepare_output_path(output_path: Path, *, overwrite: bool) -> None:
|
|
|
81
82
|
raise FileExistsError(f"output file already exists: {output_path}")
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
_MAX_INDENT_GROWTH = 8
|
|
86
|
+
_MIN_INDENT_BUDGET = 64 * 1024
|
|
87
|
+
|
|
88
|
+
|
|
84
89
|
def _format_xml_bytes(payload: bytes) -> bytes:
|
|
90
|
+
"""Re-indent an XML part, falling back to the original bytes.
|
|
91
|
+
|
|
92
|
+
Indentation is an amplifier: at the depth libxml2 accepts, every leaf gains
|
|
93
|
+
two spaces per level, so a 4-byte element can grow past 500 bytes. The
|
|
94
|
+
payload is guarded first, and a result that grew beyond the per-member
|
|
95
|
+
allowance is discarded in favour of the input.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
guard_xml_bytes(payload, part_name="XML part")
|
|
100
|
+
except HwpxSecurityError:
|
|
101
|
+
return payload
|
|
85
102
|
try:
|
|
86
103
|
element = etree.fromstring(payload)
|
|
87
104
|
except etree.XMLSyntaxError:
|
|
88
105
|
return payload
|
|
106
|
+
try:
|
|
107
|
+
guard_xml_depth(element, part_name="XML part")
|
|
108
|
+
except HwpxSecurityError:
|
|
109
|
+
return payload
|
|
89
110
|
etree.indent(element, space=" ")
|
|
90
|
-
|
|
111
|
+
formatted = etree.tostring(
|
|
91
112
|
element,
|
|
92
113
|
pretty_print=True,
|
|
93
114
|
xml_declaration=True,
|
|
94
115
|
encoding="UTF-8",
|
|
95
116
|
)
|
|
117
|
+
# Real parts grow at most ~1.7x when indented (measured across the repo's
|
|
118
|
+
# packages); anything past this is the indentation acting as an amplifier.
|
|
119
|
+
if len(formatted) > max(_MAX_INDENT_GROWTH * len(payload), _MIN_INDENT_BUDGET):
|
|
120
|
+
return payload
|
|
121
|
+
return formatted
|
|
96
122
|
|
|
97
123
|
|
|
98
124
|
def _normalize_hwpml_compat_root(rel_path: str, payload: bytes) -> bytes:
|
|
@@ -225,9 +251,10 @@ def unpack_hwpx(
|
|
|
225
251
|
_prepare_output_dir(destination, overwrite=overwrite)
|
|
226
252
|
|
|
227
253
|
with ZipFile(source_path, "r") as archive:
|
|
254
|
+
guard_zip_file(archive)
|
|
228
255
|
entries = _iter_file_entries(archive)
|
|
229
256
|
for entry in entries:
|
|
230
|
-
data = archive
|
|
257
|
+
data = read_member(archive, entry.path)
|
|
231
258
|
if pretty_xml and entry.path.endswith(_XML_SUFFIXES):
|
|
232
259
|
data = _format_xml_bytes(data)
|
|
233
260
|
target = destination / entry.path
|
|
@@ -15,7 +15,7 @@ from typing import TYPE_CHECKING
|
|
|
15
15
|
from xml.etree import ElementTree as ET
|
|
16
16
|
from zipfile import ZipFile
|
|
17
17
|
|
|
18
|
-
from ..opc.security import guard_zip_file, parse_xml_stdlib
|
|
18
|
+
from ..opc.security import guard_zip_file, parse_xml_stdlib, read_member
|
|
19
19
|
#: A caller-supplied redaction step. Declared here rather than imported from
|
|
20
20
|
#: mail_merge, which imports export_text — the two would form a cycle.
|
|
21
21
|
TextSanitizer = Callable[[str], str]
|
|
@@ -41,7 +41,7 @@ def _section_xmls(source: HwpxDocument | bytes) -> list[ET.Element]:
|
|
|
41
41
|
with ZipFile(io.BytesIO(source)) as zf:
|
|
42
42
|
guard_zip_file(zf)
|
|
43
43
|
names = sorted(n for n in zf.namelist() if _SECTION_RE.match(n))
|
|
44
|
-
return [parse_xml_stdlib(zf
|
|
44
|
+
return [parse_xml_stdlib(read_member(zf, n), part_name=n) for n in names]
|
|
45
45
|
return [sec.element for sec in source._root.sections]
|
|
46
46
|
|
|
47
47
|
|
|
@@ -28,6 +28,7 @@ import zipfile
|
|
|
28
28
|
from dataclasses import dataclass
|
|
29
29
|
|
|
30
30
|
from hwpx.document import HwpxDocument
|
|
31
|
+
from ..opc.security import guard_zip_file, read_member
|
|
31
32
|
|
|
32
33
|
__all__ = [
|
|
33
34
|
"IdempotenceReport",
|
|
@@ -78,11 +79,12 @@ def _part_contents(data: bytes) -> tuple[dict[str, bytes], list[str]]:
|
|
|
78
79
|
contents: dict[str, bytes] = {}
|
|
79
80
|
duplicates: list[str] = []
|
|
80
81
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
82
|
+
guard_zip_file(archive)
|
|
81
83
|
for info in archive.infolist():
|
|
82
84
|
name = info.filename
|
|
83
85
|
if name in contents:
|
|
84
86
|
duplicates.append(name)
|
|
85
|
-
contents[name] = archive
|
|
87
|
+
contents[name] = read_member(archive, info)
|
|
86
88
|
return contents, duplicates
|
|
87
89
|
|
|
88
90
|
|
|
@@ -20,6 +20,7 @@ import re
|
|
|
20
20
|
import xml.etree.ElementTree as ET
|
|
21
21
|
import zipfile
|
|
22
22
|
from dataclasses import dataclass
|
|
23
|
+
from ..opc.security import guard_zip_file, read_member
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"IrEqualityReport",
|
|
@@ -91,12 +92,13 @@ def project_document(data: bytes) -> list:
|
|
|
91
92
|
"""Project a whole HWPX byte blob: paragraphs across all sections in order."""
|
|
92
93
|
projection: list = []
|
|
93
94
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
95
|
+
guard_zip_file(archive)
|
|
94
96
|
names = sorted(
|
|
95
97
|
(n for n in archive.namelist() if _SECTION_RE.match(n)),
|
|
96
98
|
key=lambda n: int(_SECTION_RE.match(n).group(1)),
|
|
97
99
|
)
|
|
98
100
|
for name in names:
|
|
99
|
-
projection.extend(project_section_xml(archive
|
|
101
|
+
projection.extend(project_section_xml(read_member(archive, name)))
|
|
100
102
|
return projection
|
|
101
103
|
|
|
102
104
|
|