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.
Files changed (273) hide show
  1. {python_hwpx-6.0.2/src/python_hwpx.egg-info → python_hwpx-6.0.3}/PKG-INFO +1 -1
  2. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/pyproject.toml +1 -1
  3. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/body_patch.py +10 -6
  4. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/ingest/hwpx_converter.py +13 -2
  5. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/layout/lint.py +3 -1
  6. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/mutation_report.py +3 -1
  7. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/opc/package.py +2 -2
  8. python_hwpx-6.0.3/src/hwpx/opc/security.py +240 -0
  9. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/patch.py +11 -2
  10. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/save_pipeline.py +3 -1
  11. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/table_patch.py +11 -5
  12. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/archive_cli.py +29 -2
  13. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/exporter.py +2 -2
  14. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/idempotence.py +3 -1
  15. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/ir_equality.py +3 -1
  16. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/layout_preview.py +2 -2
  17. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/package_validator.py +8 -6
  18. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/repair.py +10 -1
  19. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/text_extractor.py +11 -4
  20. {python_hwpx-6.0.2 → python_hwpx-6.0.3/src/python_hwpx.egg-info}/PKG-INFO +1 -1
  21. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_opc_package.py +259 -1
  22. python_hwpx-6.0.2/src/hwpx/opc/security.py +0 -134
  23. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/LICENSE +0 -0
  24. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/NOTICE +0 -0
  25. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/README.md +0 -0
  26. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/setup.cfg +0 -0
  27. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/__init__.py +0 -0
  28. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/__init__.py +0 -0
  29. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/_legacy.py +0 -0
  30. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/_resolve.py +0 -0
  31. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/_units.py +0 -0
  32. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/fields.py +0 -0
  33. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/headings.py +0 -0
  34. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/layout.py +0 -0
  35. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/media.py +0 -0
  36. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/memos.py +0 -0
  37. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/__init__.py +0 -0
  38. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/_base.py +0 -0
  39. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/fields.py +0 -0
  40. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/media.py +0 -0
  41. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/notes.py +0 -0
  42. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/page.py +0 -0
  43. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/parts.py +0 -0
  44. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/refs.py +0 -0
  45. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/shapes.py +0 -0
  46. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/styles.py +0 -0
  47. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/tables.py +0 -0
  48. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/text.py +0 -0
  49. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/ns/tracking.py +0 -0
  50. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/persistence.py +0 -0
  51. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/shapes.py +0 -0
  52. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/_document/tracked.py +0 -0
  53. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/capabilities.py +0 -0
  54. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/Skeleton.hwpx +0 -0
  55. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/known-traps.md +0 -0
  56. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/mutation-semantics.md +0 -0
  57. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/recipes-traversal.md +0 -0
  58. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/data/contract_docs/support-matrix.md +0 -0
  59. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/document.py +0 -0
  60. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/__init__.py +0 -0
  61. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/authoring.py +0 -0
  62. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/eqedit.py +0 -0
  63. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/mathml.py +0 -0
  64. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/render.py +0 -0
  65. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/equation/tokens.py +0 -0
  66. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/errors.py +0 -0
  67. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/experimental.py +0 -0
  68. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/__init__.py +0 -0
  69. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/apply.py +0 -0
  70. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/engine.py +0 -0
  71. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/measure.py +0 -0
  72. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/policy.py +0 -0
  73. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/form_fit/report.py +0 -0
  74. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/ingest/__init__.py +0 -0
  75. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/ingest/base.py +0 -0
  76. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/layout/__init__.py +0 -0
  77. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/layout/report.py +0 -0
  78. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/model.py +0 -0
  79. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/__init__.py +0 -0
  80. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/binary_item.py +0 -0
  81. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/checkbox.py +0 -0
  82. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/form_field.py +0 -0
  83. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/results.py +0 -0
  84. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/objects/tracked.py +0 -0
  85. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/opc/relationships.py +0 -0
  86. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/opc/xml_utils.py +0 -0
  87. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/__init__.py +0 -0
  88. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/_document_impl.py +0 -0
  89. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/_document_primitives.py +0 -0
  90. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/body.py +0 -0
  91. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/canonical_defaults.py +0 -0
  92. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/color.py +0 -0
  93. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/common.py +0 -0
  94. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/document.py +0 -0
  95. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/document_parts.py +0 -0
  96. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/header.py +0 -0
  97. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/header_part.py +0 -0
  98. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/memo.py +0 -0
  99. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/namespaces.py +0 -0
  100. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/numbering.py +0 -0
  101. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/objects.py +0 -0
  102. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/paragraph.py +0 -0
  103. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/parser.py +0 -0
  104. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/run.py +0 -0
  105. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/schema.py +0 -0
  106. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/section.py +0 -0
  107. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/section_format.py +0 -0
  108. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/section_story.py +0 -0
  109. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/simple_parts.py +0 -0
  110. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/table.py +0 -0
  111. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/oxml/utils.py +0 -0
  112. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/package.py +0 -0
  113. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/__init__.py +0 -0
  114. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_execute.py +0 -0
  115. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_model.py +0 -0
  116. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_schema.py +0 -0
  117. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/plan/_validate.py +0 -0
  118. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/py.typed +0 -0
  119. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/__init__.py +0 -0
  120. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/ledger.py +0 -0
  121. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/policy.py +0 -0
  122. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/rendering.py +0 -0
  123. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/quality/report.py +0 -0
  124. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/templates.py +0 -0
  125. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/__init__.py +0 -0
  126. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/_schemas/header.xsd +0 -0
  127. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/_schemas/section.xsd +0 -0
  128. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/doc_diff.py +0 -0
  129. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/document_viewer.py +0 -0
  130. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/generic_inventory.py +0 -0
  131. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/id_integrity.py +0 -0
  132. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/mail_merge.py +0 -0
  133. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/markdown_export.py +0 -0
  134. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/object_finder.py +0 -0
  135. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/package_reconcile.py +0 -0
  136. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/page_guard.py +0 -0
  137. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/read_fidelity.py +0 -0
  138. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/recover.py +0 -0
  139. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/redline.py +0 -0
  140. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/report_utils.py +0 -0
  141. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/roundtrip_diff.py +0 -0
  142. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/table_cleanup.py +0 -0
  143. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/table_navigation.py +0 -0
  144. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/template_analyzer.py +0 -0
  145. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/text_extract_cli.py +0 -0
  146. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/toc_author.py +0 -0
  147. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/toc_fidelity.py +0 -0
  148. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/hwpx/tools/validator.py +0 -0
  149. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/SOURCES.txt +0 -0
  150. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  151. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  152. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/requires.txt +0 -0
  153. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/src/python_hwpx.egg-info/top_level.txt +0 -0
  154. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_add_control.py +0 -0
  155. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_add_heading.py +0 -0
  156. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_adoption_metrics_snapshot.py +0 -0
  157. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_authoring_defaults.py +0 -0
  158. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_authoring_quality_corpus.py +0 -0
  159. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_blind_eval_fixture.py +0 -0
  160. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_body_patch.py +0 -0
  161. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_byte_patch_identity.py +0 -0
  162. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_capabilities_surface.py +0 -0
  163. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_cell_line_spacing.py +0 -0
  164. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_chart_authoring.py +0 -0
  165. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_check_box_authoring.py +0 -0
  166. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_comment_node_robustness.py +0 -0
  167. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_contract_docs_sync.py +0 -0
  168. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_corpus_read_fidelity.py +0 -0
  169. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_coverage_ledger.py +0 -0
  170. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_coverage_promotion.py +0 -0
  171. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_coverage_targets.py +0 -0
  172. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_deviations_registry.py +0 -0
  173. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_doc_diff.py +0 -0
  174. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_context_manager.py +0 -0
  175. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_facade_surface.py +0 -0
  176. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_formatting.py +0 -0
  177. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_ops_runtime_boundary.py +0 -0
  178. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_save_api.py +0 -0
  179. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_document_viewer.py +0 -0
  180. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_documentation_code_fences.py +0 -0
  181. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_edit_plan.py +0 -0
  182. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_engine_surface_6_0.py +0 -0
  183. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_equation_authoring.py +0 -0
  184. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_equation_converter.py +0 -0
  185. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_equation_render.py +0 -0
  186. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_error_contract.py +0 -0
  187. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_error_messages.py +0 -0
  188. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_evalplan_core_primitives.py +0 -0
  189. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_existing_document_format_editing.py +0 -0
  190. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_field_authoring.py +0 -0
  191. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fields.py +0 -0
  192. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fill_core_primitives.py +0 -0
  193. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fit.py +0 -0
  194. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_form_fit_integration.py +0 -0
  195. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_fuzz_catalog_derive_expected.py +0 -0
  196. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_fuzz_loop.py +0 -0
  197. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_fuzz_regressions.py +0 -0
  198. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_gap_closure_tools.py +0 -0
  199. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_golden_api.py +0 -0
  200. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_hp_tab_support.py +0 -0
  201. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_hwpxlib_corpus_read.py +0 -0
  202. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_id_generator_range.py +0 -0
  203. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_image_object_workflow.py +0 -0
  204. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ingest.py +0 -0
  205. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_inline_models.py +0 -0
  206. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_integration_hwpx_compatibility.py +0 -0
  207. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_integration_roundtrip.py +0 -0
  208. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_layout_cache_scope.py +0 -0
  209. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_layout_lint.py +0 -0
  210. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_layout_preview.py +0 -0
  211. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_library_boundary_closure.py +0 -0
  212. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_llms_txt.py +0 -0
  213. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_mail_merge_xlsx.py +0 -0
  214. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_markdown_export.py +0 -0
  215. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_memo_and_style_editing.py +0 -0
  216. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_moved_surface_hints.py +0 -0
  217. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_mutation_report.py +0 -0
  218. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_namespace_handling.py +0 -0
  219. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_namespace_hygiene.py +0 -0
  220. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_new_features.py +0 -0
  221. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_note_authoring_contract.py +0 -0
  222. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_fields.py +0 -0
  223. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_media.py +0 -0
  224. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_notes.py +0 -0
  225. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_page.py +0 -0
  226. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_refs.py +0 -0
  227. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_shapes.py +0 -0
  228. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_tables.py +0 -0
  229. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_ns_tracking.py +0 -0
  230. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_open_safety_corpus.py +0 -0
  231. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_oxml_modularization.py +0 -0
  232. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_oxml_parsing.py +0 -0
  233. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_packaging_license_metadata.py +0 -0
  234. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_packaging_py_typed.py +0 -0
  235. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_paragraph_keep_together.py +0 -0
  236. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_paragraph_section_management.py +0 -0
  237. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_product_boundary.py +0 -0
  238. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_public_artifact_hygiene.py +0 -0
  239. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_public_runtime_boundary.py +0 -0
  240. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_read_fidelity.py +0 -0
  241. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_recover_broken_zip.py +0 -0
  242. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_redline_authoring.py +0 -0
  243. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_redline_verify.py +0 -0
  244. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_release_workflow_safety.py +0 -0
  245. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_repair_repack.py +0 -0
  246. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_report_utils.py +0 -0
  247. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_repr_snapshots.py +0 -0
  248. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_return_contract.py +0 -0
  249. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_roundtrip_fidelity.py +0 -0
  250. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_row_heights.py +0 -0
  251. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_save_pipeline.py +0 -0
  252. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_section_headers.py +0 -0
  253. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_section_properties_real_roundtrip.py +0 -0
  254. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_set_paragraph_format_keep.py +0 -0
  255. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_shape_geometry_contract.py +0 -0
  256. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_skeleton_template_ids.py +0 -0
  257. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_split_cell.py +0 -0
  258. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_split_merged_cell.py +0 -0
  259. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_stable_surface.py +0 -0
  260. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_style_resolution.py +0 -0
  261. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_cleanup.py +0 -0
  262. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_navigation.py +0 -0
  263. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_patch.py +0 -0
  264. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_patch_dryrun.py +0 -0
  265. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_table_patch_m105.py +0 -0
  266. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_tables_default_border.py +0 -0
  267. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_template_analyzer_enrichment.py +0 -0
  268. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_text_extractor_annotations.py +0 -0
  269. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_toc_author.py +0 -0
  270. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_toc_fidelity.py +0 -0
  271. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_validation_severity.py +0 -0
  272. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_validator_comment_nodes.py +0 -0
  273. {python_hwpx-6.0.2 → python_hwpx-6.0.3}/tests/test_version_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 6.0.2
3
+ Version: 6.0.3
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "6.0.2"
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.read(header_name).decode("utf-8") if header_name else ""
446
- sections = {n: z.read(n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
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.read(header_name).decode("utf-8") if header_name else ""
517
- sections = {n: z.read(n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
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.read(n).decode("utf-8")
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.read(header_part).decode("utf-8") if header_part else ""
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 = archive.read("mimetype").decode("utf-8", "replace").strip()
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.read(info.filename))))
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.read(info.filename)
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 = {info.filename: zf.read(info.filename) for info in infos}
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
- parts = {info.filename: archive.read(info.filename) for info in archive.infolist() if not info.is_dir()}
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.read(info.filename))
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
- ET.fromstring(archive.read(name))
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.read(info.filename)
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
- parts = {i.filename: zf.read(i.filename) for i in zf.infolist() if not i.is_dir()}
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
- parts = {i.filename: zf.read(i.filename) for i in zf.infolist() if not i.is_dir()}
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
- return {n: z.read(n) for n in z.namelist() if re.search(r"section\d+\.xml$", n)}
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.read(name).decode("utf-8") for name in section_names}
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
- return etree.tostring(
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.read(entry.path)
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.read(n), part_name=n) for n in names]
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.read(info)
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.read(name)))
101
+ projection.extend(project_section_xml(read_member(archive, name)))
100
102
  return projection
101
103
 
102
104