From f2068cad5472c782678d2f62c236baa8478b86e6 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:04:35 -0700 Subject: [PATCH 1/7] fix: detect changed cached formula results --- examples/formula-integrity/failed-report.json | 2 +- src/spreadsheet_guard/_ooxml_fidelity.py | 5 +- tests/test_standalone_engine.py | 47 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/examples/formula-integrity/failed-report.json b/examples/formula-integrity/failed-report.json index 6cbfa12..6c42ad1 100644 --- a/examples/formula-integrity/failed-report.json +++ b/examples/formula-integrity/failed-report.json @@ -1 +1 @@ -{"after":{"filename":"after-formula-damaged.xlsx","sha256":"53aeab0f4e21e7d0436c07350be75a29702f0e4ef39f27e12768033dc60e0738","size_bytes":5001},"before":{"filename":"before.xlsx","sha256":"9b0cf7aec3ce0013dfc159c076b56412b6e6f53bc74f6baa2ed775883cb1cbb0","size_bytes":5020},"fidelity_audit":{"after":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"after-formula-damaged.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"before":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"before.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"issue_count":1,"issues":[{"after_empty":false,"before_empty":false,"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [((('r', 'A3'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A2)'), ((('r', 'A4'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2')]} after={'xl/worksheets/sheet1.xml': [((('r', 'A3'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A1)'), ((('r', 'A4'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2')]}","part":"worksheet_formulas","severity":"error"}]},"not_assessed":["intended change authorization","calculation correctness","rendered appearance","macro execution"],"policy":{"external_link_inventory":{"mode":"unchanged"},"formula_integrity":{"mode":"unchanged"},"macro_inventory":{"mode":"unchanged"},"package_integrity":{"mode":"unchanged"},"worksheet_inventory":{"mode":"unchanged"}},"policy_decisions":{"external_link_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"formula_integrity":{"evidence":{"after_formula_count":2,"before_formula_count":2},"findings":[{"kind":"formula_changed","message":"worksheet formulas changed between before and after workbooks"}],"status":"failed"},"macro_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"package_integrity":{"evidence":{"issue_count":1},"findings":[{"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [((('r', 'A3'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A2)'), ((('r', 'A4'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2')]} after={'xl/worksheets/sheet1.xml': [((('r', 'A3'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A1)'), ((('r', 'A4'),), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2')]}","part":"worksheet_formulas"}],"status":"failed"},"worksheet_inventory":{"evidence":{"after_count":1,"after_items":["Sheet"],"before_count":1,"before_items":["Sheet"]},"findings":[],"status":"passed"}},"schema_version":1,"status":"failed"} +{"after":{"filename":"after-formula-damaged.xlsx","sha256":"53aeab0f4e21e7d0436c07350be75a29702f0e4ef39f27e12768033dc60e0738","size_bytes":5001},"before":{"filename":"before.xlsx","sha256":"9b0cf7aec3ce0013dfc159c076b56412b6e6f53bc74f6baa2ed775883cb1cbb0","size_bytes":5020},"fidelity_audit":{"after":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"after-formula-damaged.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"before":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"before.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"issue_count":1,"issues":[{"after_empty":false,"before_empty":false,"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A2)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]} after={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A1)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]}","part":"worksheet_formulas","severity":"error"}]},"not_assessed":["intended change authorization","calculation correctness","rendered appearance","macro execution"],"policy":{"external_link_inventory":{"mode":"unchanged"},"formula_integrity":{"mode":"unchanged"},"macro_inventory":{"mode":"unchanged"},"package_integrity":{"mode":"unchanged"},"worksheet_inventory":{"mode":"unchanged"}},"policy_decisions":{"external_link_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"formula_integrity":{"evidence":{"after_formula_count":2,"before_formula_count":2},"findings":[{"kind":"formula_changed","message":"worksheet formulas changed between before and after workbooks"}],"status":"failed"},"macro_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"package_integrity":{"evidence":{"issue_count":1},"findings":[{"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A2)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]} after={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A1)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]}","part":"worksheet_formulas"}],"status":"failed"},"worksheet_inventory":{"evidence":{"after_count":1,"after_items":["Sheet"],"before_count":1,"before_items":["Sheet"]},"findings":[],"status":"passed"}},"schema_version":1,"status":"failed"} diff --git a/src/spreadsheet_guard/_ooxml_fidelity.py b/src/spreadsheet_guard/_ooxml_fidelity.py index ad53992..f045122 100644 --- a/src/spreadsheet_guard/_ooxml_fidelity.py +++ b/src/spreadsheet_guard/_ooxml_fidelity.py @@ -1683,11 +1683,14 @@ def _worksheet_formula_fingerprint( formula = _first_child_by_local(cell, "f") if formula is None: continue + cached_value = _first_child_by_local(cell, "v") formulas.append( ( - _stable_attrs(cell, ("r",)), + _stable_attrs(cell, ("r", "t")), _stable_attrs(formula, ("t", "ref", "si", "ca", "bx")), _text(formula), + cached_value is not None, + _text(cached_value) if cached_value is not None else None, ) ) if formulas: diff --git a/tests/test_standalone_engine.py b/tests/test_standalone_engine.py index 759dc00..93debf1 100644 --- a/tests/test_standalone_engine.py +++ b/tests/test_standalone_engine.py @@ -2,6 +2,7 @@ import hashlib import json +import zipfile from pathlib import Path from spreadsheet_guard import guard_workbooks @@ -13,6 +14,28 @@ def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def _replace_archive_member( + source: Path, + destination: Path, + member: str, + before: bytes, + after: bytes, +) -> None: + replaced = False + with ( + zipfile.ZipFile(source) as source_archive, + zipfile.ZipFile(destination, "w") as destination_archive, + ): + for info in source_archive.infolist(): + data = source_archive.read(info.filename) + if info.filename == member: + assert data.count(before) == 1 + data = data.replace(before, after) + replaced = True + destination_archive.writestr(info, data) + assert replaced + + def test_bundled_engine_accepts_an_intact_workbook(tmp_path: Path) -> None: before = PROOF_DIR / "before.xlsx" after = PROOF_DIR / "after-intact.xlsx" @@ -44,3 +67,27 @@ def test_bundled_engine_rejects_a_formula_change(tmp_path: Path) -> None: "message": "worksheet formulas changed between before and after workbooks", } ] + + +def test_bundled_engine_rejects_a_cached_formula_result_change( + tmp_path: Path, +) -> None: + before = PROOF_DIR / "before.xlsx" + after = tmp_path / "after-cached-result-damaged.xlsx" + output = tmp_path / "failed-cached-result-report.json" + _replace_archive_member( + before, + after, + "xl/worksheets/sheet1.xml", + b'SUM(A1:A2)0', + b'SUM(A1:A2)999', + ) + + outcome = guard_workbooks(before, after, output) + + assert outcome.status == "failed" + package_decision = outcome.report["policy_decisions"]["package_integrity"] + assert package_decision["status"] == "failed" + assert {finding["kind"] for finding in package_decision["findings"]} == { + "worksheet_formulas_semantic_drift" + } From bea8ff824bb99c92904496d7d6579d31b5291ca7 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:09:19 -0700 Subject: [PATCH 2/7] fix: retain expanded formula fingerprints --- src/spreadsheet_guard/_ooxml_fidelity.py | 4 +-- tests/test_standalone_engine.py | 37 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/spreadsheet_guard/_ooxml_fidelity.py b/src/spreadsheet_guard/_ooxml_fidelity.py index f045122..fb348cd 100644 --- a/src/spreadsheet_guard/_ooxml_fidelity.py +++ b/src/spreadsheet_guard/_ooxml_fidelity.py @@ -1749,7 +1749,7 @@ def _structured_reference_fingerprint( formula for formula in formulas if isinstance(formula, tuple) - and len(formula) == 3 + and len(formula) >= 3 and isinstance(formula[2], str) and _is_structured_reference_formula(formula[2]) ] @@ -2051,7 +2051,7 @@ def _worksheet_formula_texts( for formula in formulas: if not ( isinstance(formula, tuple) - and len(formula) == 3 + and len(formula) >= 3 and isinstance(formula[2], str) ): continue diff --git a/tests/test_standalone_engine.py b/tests/test_standalone_engine.py index 93debf1..aa08485 100644 --- a/tests/test_standalone_engine.py +++ b/tests/test_standalone_engine.py @@ -5,6 +5,8 @@ import zipfile from pathlib import Path +import pytest + from spreadsheet_guard import guard_workbooks PROOF_DIR = Path(__file__).parents[1] / "examples" / "formula-integrity" @@ -91,3 +93,38 @@ def test_bundled_engine_rejects_a_cached_formula_result_change( assert {finding["kind"] for finding in package_decision["findings"]} == { "worksheet_formulas_semantic_drift" } + + +@pytest.mark.parametrize( + ("formula_text", "fingerprint_name"), + [ + (b"SUM(Table1[Amount])", "structured_references"), + (b"'[Other.xlsx]Sheet1'!A1", "external_links"), + ], +) +def test_expanded_formula_fingerprint_remains_visible_to_consumers( + tmp_path: Path, + formula_text: bytes, + fingerprint_name: str, +) -> None: + before = tmp_path / f"{fingerprint_name}-before.xlsx" + after = tmp_path / f"{fingerprint_name}-after.xlsx" + for destination in (before, after): + _replace_archive_member( + PROOF_DIR / "before.xlsx", + destination, + "xl/worksheets/sheet1.xml", + b"SUM(A1:A2)", + formula_text, + ) + + outcome = guard_workbooks( + before, + after, + tmp_path / f"{fingerprint_name}-report.json", + ) + + assert outcome.status == "passed" + for side in ("before", "after"): + counts = outcome.report["fidelity_audit"][side]["semantic_fingerprint_counts"] + assert counts[fingerprint_name] > 0 From 554afe2d91aee8adf64265d04b1cce605b2e146d Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:16:50 -0700 Subject: [PATCH 3/7] fix: preserve array formula cached results --- examples/formula-integrity/failed-report.json | 2 +- src/spreadsheet_guard/_ooxml_fidelity.py | 110 +++++++++++++----- tests/test_standalone_engine.py | 70 +++++++++++ 3 files changed, 153 insertions(+), 29 deletions(-) diff --git a/examples/formula-integrity/failed-report.json b/examples/formula-integrity/failed-report.json index 6c42ad1..10d5cbb 100644 --- a/examples/formula-integrity/failed-report.json +++ b/examples/formula-integrity/failed-report.json @@ -1 +1 @@ -{"after":{"filename":"after-formula-damaged.xlsx","sha256":"53aeab0f4e21e7d0436c07350be75a29702f0e4ef39f27e12768033dc60e0738","size_bytes":5001},"before":{"filename":"before.xlsx","sha256":"9b0cf7aec3ce0013dfc159c076b56412b6e6f53bc74f6baa2ed775883cb1cbb0","size_bytes":5020},"fidelity_audit":{"after":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"after-formula-damaged.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"before":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"before.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"issue_count":1,"issues":[{"after_empty":false,"before_empty":false,"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A2)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]} after={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A1)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]}","part":"worksheet_formulas","severity":"error"}]},"not_assessed":["intended change authorization","calculation correctness","rendered appearance","macro execution"],"policy":{"external_link_inventory":{"mode":"unchanged"},"formula_integrity":{"mode":"unchanged"},"macro_inventory":{"mode":"unchanged"},"package_integrity":{"mode":"unchanged"},"worksheet_inventory":{"mode":"unchanged"}},"policy_decisions":{"external_link_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"formula_integrity":{"evidence":{"after_formula_count":2,"before_formula_count":2},"findings":[{"kind":"formula_changed","message":"worksheet formulas changed between before and after workbooks"}],"status":"failed"},"macro_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"package_integrity":{"evidence":{"issue_count":1},"findings":[{"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A2)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]} after={'xl/worksheets/sheet1.xml': [((('r', 'A3'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'SUM(A1:A1)', True, '0'), ((('r', 'A4'), ('t', None)), (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'A3*2', True, '0')]}","part":"worksheet_formulas"}],"status":"failed"},"worksheet_inventory":{"evidence":{"after_count":1,"after_items":["Sheet"],"before_count":1,"before_items":["Sheet"]},"findings":[],"status":"passed"}},"schema_version":1,"status":"failed"} +{"after":{"filename":"after-formula-damaged.xlsx","sha256":"53aeab0f4e21e7d0436c07350be75a29702f0e4ef39f27e12768033dc60e0738","size_bytes":5001},"before":{"filename":"before.xlsx","sha256":"9b0cf7aec3ce0013dfc159c076b56412b6e6f53bc74f6baa2ed775883cb1cbb0","size_bytes":5020},"fidelity_audit":{"after":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"after-formula-damaged.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"before":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"before.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"issue_count":1,"issues":[{"after_empty":false,"before_empty":false,"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A2)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]} after={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A1)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]}","part":"worksheet_formulas","severity":"error"}]},"not_assessed":["intended change authorization","calculation correctness","rendered appearance","macro execution"],"policy":{"external_link_inventory":{"mode":"unchanged"},"formula_integrity":{"mode":"unchanged"},"macro_inventory":{"mode":"unchanged"},"package_integrity":{"mode":"unchanged"},"worksheet_inventory":{"mode":"unchanged"}},"policy_decisions":{"external_link_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"formula_integrity":{"evidence":{"after_formula_count":2,"before_formula_count":2},"findings":[{"kind":"formula_changed","message":"worksheet formulas changed between before and after workbooks"}],"status":"failed"},"macro_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"package_integrity":{"evidence":{"issue_count":1},"findings":[{"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A2)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]} after={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A1)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]}","part":"worksheet_formulas"}],"status":"failed"},"worksheet_inventory":{"evidence":{"after_count":1,"after_items":["Sheet"],"before_count":1,"before_items":["Sheet"]},"findings":[],"status":"passed"}},"schema_version":1,"status":"failed"} diff --git a/src/spreadsheet_guard/_ooxml_fidelity.py b/src/spreadsheet_guard/_ooxml_fidelity.py index fb348cd..37b19b5 100644 --- a/src/spreadsheet_guard/_ooxml_fidelity.py +++ b/src/spreadsheet_guard/_ooxml_fidelity.py @@ -1085,14 +1085,47 @@ def _range_width(ref: str) -> int | None: return abs(last_col - first_col) + 1 -def _cell_col_index(cell: str) -> int | None: - match = re.match(r"^\$?([A-Za-z]+)\$?\d+$", cell) +def _cell_coordinate(cell: str) -> tuple[int, int] | None: + match = re.fullmatch(r"\$?([A-Za-z]+)\$?(\d+)", cell) if match is None: return None - value = 0 + column = 0 for char in match.group(1).upper(): - value = value * 26 + (ord(char) - ord("A") + 1) - return value + column = column * 26 + (ord(char) - ord("A") + 1) + row = int(match.group(2)) + return (column, row) if row > 0 else None + + +def _cell_range_bounds(ref: str | None) -> tuple[int, int, int, int] | None: + if ref is None: + return None + first, separator, last = ref.partition(":") + first_coordinate = _cell_coordinate(first) + last_coordinate = _cell_coordinate(last if separator else first) + if first_coordinate is None or last_coordinate is None: + return None + first_column, first_row = first_coordinate + last_column, last_row = last_coordinate + return ( + min(first_column, last_column), + min(first_row, last_row), + max(first_column, last_column), + max(first_row, last_row), + ) + + +def _coordinate_in_range( + coordinate: tuple[int, int], + bounds: tuple[int, int, int, int], +) -> bool: + column, row = coordinate + first_column, first_row, last_column, last_row = bounds + return first_column <= column <= last_column and first_row <= row <= last_row + + +def _cell_col_index(cell: str) -> int | None: + coordinate = _cell_coordinate(cell) + return coordinate[0] if coordinate is not None else None def _read_semantic_fingerprints( @@ -1676,23 +1709,47 @@ def _worksheet_formula_fingerprint( root = _read_xml_or_none(archive, part) if root is None: continue - formulas: list[object] = [] + cells: list[tuple[ElementTree.Element, ElementTree.Element | None]] = [] + array_ranges: list[tuple[int, int, int, int]] = [] for cell in root.iter(): if _local_name(cell.tag) != "c": continue formula = _first_child_by_local(cell, "f") + cells.append((cell, formula)) + if formula is not None and _attr(formula, "t") == "array": + bounds = _cell_range_bounds(_attr(formula, "ref")) + if bounds is not None: + array_ranges.append(bounds) + + formulas: list[object] = [] + for cell, formula in cells: if formula is None: - continue + cell_reference = _attr(cell, "r") + coordinate = ( + _cell_coordinate(cell_reference) + if cell_reference is not None + else None + ) + if coordinate is None or not any( + _coordinate_in_range(coordinate, bounds) for bounds in array_ranges + ): + continue cached_value = _first_child_by_local(cell, "v") - formulas.append( - ( - _stable_attrs(cell, ("r", "t")), - _stable_attrs(formula, ("t", "ref", "si", "ca", "bx")), - _text(formula), + fingerprint: dict[str, object] = { + "kind": "formula" if formula is not None else "array_result", + "cell": _stable_attrs(cell, ("r", "t")), + "cached_value": ( cached_value is not None, - _text(cached_value) if cached_value is not None else None, + cached_value.text if cached_value is not None else None, + ), + } + if formula is not None: + fingerprint["formula"] = _stable_attrs( + formula, + ("t", "ref", "si", "ca", "bx"), ) - ) + fingerprint["text"] = _text(formula) + formulas.append(fingerprint) if formulas: out[part] = formulas return out @@ -1745,14 +1802,13 @@ def _structured_reference_fingerprint( if formulas_by_part is None: formulas_by_part = _worksheet_formula_fingerprint(archive, parts) for part, formulas in formulas_by_part.items(): - structured = [ - formula - for formula in formulas - if isinstance(formula, tuple) - and len(formula) >= 3 - and isinstance(formula[2], str) - and _is_structured_reference_formula(formula[2]) - ] + structured: list[Mapping[str, object]] = [] + for formula in formulas: + if not isinstance(formula, Mapping): + continue + text = formula.get("text") + if isinstance(text, str) and _is_structured_reference_formula(text): + structured.append(formula) if structured: out[part] = structured return out @@ -2049,13 +2105,11 @@ def _worksheet_formula_texts( for part, formulas in formulas_by_part.items(): texts: list[str] = [] for formula in formulas: - if not ( - isinstance(formula, tuple) - and len(formula) >= 3 - and isinstance(formula[2], str) - ): + if not isinstance(formula, Mapping): + continue + text = formula.get("text") + if not isinstance(text, str): continue - text = formula[2] if external_only and not _is_external_workbook_formula(text): continue texts.append(text) diff --git a/tests/test_standalone_engine.py b/tests/test_standalone_engine.py index aa08485..a954031 100644 --- a/tests/test_standalone_engine.py +++ b/tests/test_standalone_engine.py @@ -95,6 +95,76 @@ def test_bundled_engine_rejects_a_cached_formula_result_change( } +def test_bundled_engine_rejects_a_non_anchor_array_cached_result_change( + tmp_path: Path, +) -> None: + before = tmp_path / "array-before.xlsx" + after = tmp_path / "array-after.xlsx" + _replace_archive_member( + PROOF_DIR / "before.xlsx", + before, + "xl/worksheets/sheet1.xml", + ( + b'SUM(A1:A2)0' + b'A3*20' + ), + ( + b'SUM(A1:A2)30' + b'60' + ), + ) + _replace_archive_member( + before, + after, + "xl/worksheets/sheet1.xml", + b'60', + b'999', + ) + + outcome = guard_workbooks( + before, + after, + tmp_path / "array-cached-result-report.json", + ) + + assert outcome.status == "failed" + package_decision = outcome.report["policy_decisions"]["package_integrity"] + assert package_decision["status"] == "failed" + assert {finding["kind"] for finding in package_decision["findings"]} == { + "worksheet_formulas_semantic_drift" + } + + +def test_bundled_engine_preserves_cached_string_whitespace(tmp_path: Path) -> None: + before = tmp_path / "string-before.xlsx" + after = tmp_path / "string-after.xlsx" + original = b'SUM(A1:A2)0' + for destination, cached_value in ( + (before, b" hello "), + (after, b"hello"), + ): + _replace_archive_member( + PROOF_DIR / "before.xlsx", + destination, + "xl/worksheets/sheet1.xml", + original, + (b'SUM(A1:A2)' + cached_value + b""), + ) + + outcome = guard_workbooks( + before, + after, + tmp_path / "string-cached-result-report.json", + ) + + assert outcome.status == "failed" + package_decision = outcome.report["policy_decisions"]["package_integrity"] + assert package_decision["status"] == "failed" + assert {finding["kind"] for finding in package_decision["findings"]} == { + "worksheet_formulas_semantic_drift" + } + + @pytest.mark.parametrize( ("formula_text", "fingerprint_name"), [ From 110d28a479d480c988052e5861e4e640dd917a7e Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:25:19 -0700 Subject: [PATCH 4/7] fix: canonicalize formula cache fingerprints --- examples/formula-integrity/failed-report.json | 2 +- src/spreadsheet_guard/_ooxml_fidelity.py | 191 ++++++++++++++---- tests/test_standalone_engine.py | 39 +++- 3 files changed, 189 insertions(+), 43 deletions(-) diff --git a/examples/formula-integrity/failed-report.json b/examples/formula-integrity/failed-report.json index 10d5cbb..1831276 100644 --- a/examples/formula-integrity/failed-report.json +++ b/examples/formula-integrity/failed-report.json @@ -1 +1 @@ -{"after":{"filename":"after-formula-damaged.xlsx","sha256":"53aeab0f4e21e7d0436c07350be75a29702f0e4ef39f27e12768033dc60e0738","size_bytes":5001},"before":{"filename":"before.xlsx","sha256":"9b0cf7aec3ce0013dfc159c076b56412b6e6f53bc74f6baa2ed775883cb1cbb0","size_bytes":5020},"fidelity_audit":{"after":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"after-formula-damaged.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"before":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"before.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"issue_count":1,"issues":[{"after_empty":false,"before_empty":false,"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A2)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]} after={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A1)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]}","part":"worksheet_formulas","severity":"error"}]},"not_assessed":["intended change authorization","calculation correctness","rendered appearance","macro execution"],"policy":{"external_link_inventory":{"mode":"unchanged"},"formula_integrity":{"mode":"unchanged"},"macro_inventory":{"mode":"unchanged"},"package_integrity":{"mode":"unchanged"},"worksheet_inventory":{"mode":"unchanged"}},"policy_decisions":{"external_link_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"formula_integrity":{"evidence":{"after_formula_count":2,"before_formula_count":2},"findings":[{"kind":"formula_changed","message":"worksheet formulas changed between before and after workbooks"}],"status":"failed"},"macro_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"package_integrity":{"evidence":{"issue_count":1},"findings":[{"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A2)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]} after={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (('r', 'A3'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A1)'}, {'kind': 'formula', 'cell': (('r', 'A4'), ('t', None)), 'cached_value': (True, '0'), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]}","part":"worksheet_formulas"}],"status":"failed"},"worksheet_inventory":{"evidence":{"after_count":1,"after_items":["Sheet"],"before_count":1,"before_items":["Sheet"]},"findings":[],"status":"passed"}},"schema_version":1,"status":"failed"} +{"after":{"filename":"after-formula-damaged.xlsx","sha256":"53aeab0f4e21e7d0436c07350be75a29702f0e4ef39f27e12768033dc60e0738","size_bytes":5001},"before":{"filename":"before.xlsx","sha256":"9b0cf7aec3ce0013dfc159c076b56412b6e6f53bc74f6baa2ed775883cb1cbb0","size_bytes":5020},"fidelity_audit":{"after":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"after-formula-damaged.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"before":{"cf_dxf_ref_count":0,"chart_sheet_ref_issue_count":0,"content_override_count":6,"dxfs_count":0,"feature_part_counts":{"calc_chain":0,"chart":0,"chart_sheet":0,"chart_style":0,"comment":0,"conditional_formatting":2,"connection":0,"custom_property":0,"custom_xml":0,"data_model":0,"doc_metadata":0,"drawing":0,"embedded_object":0,"external_link":0,"image_media":0,"javascript_project":0,"named_sheet_view":0,"pivot":0,"printer_settings":0,"python":0,"sheet_metadata":0,"slicer":0,"table":0,"timeline":0,"vba":0},"part_count":9,"path":"before.xlsx","pivot_sheet_ref_issue_count":0,"relationship_count":6,"semantic_fingerprint_counts":{"chart_sheets":0,"chart_styles":0,"charts":0,"conditional_formatting":0,"connections":0,"data_model":0,"data_validations":0,"drawing_objects":0,"extensions":0,"external_links":0,"named_sheet_views":0,"page_setup":1,"pivots":0,"python":0,"sheet_metadata":0,"slicers":0,"structured_references":0,"style_theme":2,"timelines":0,"workbook_globals":1,"worksheet_formulas":1},"table_integrity_issue_count":0,"workbook_sheet_ref_issue_count":0,"worksheet_sheet_ref_issue_count":0,"xml_parse_error_count":0},"issue_count":1,"issues":[{"after_empty":false,"before_empty":false,"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (1, 3), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A2)'}, {'kind': 'formula', 'cell': (1, 4), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]} after={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (1, 3), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A1)'}, {'kind': 'formula', 'cell': (1, 4), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]}","part":"worksheet_formulas","severity":"error"}]},"not_assessed":["intended change authorization","calculation correctness","rendered appearance","macro execution"],"policy":{"external_link_inventory":{"mode":"unchanged"},"formula_integrity":{"mode":"unchanged"},"macro_inventory":{"mode":"unchanged"},"package_integrity":{"mode":"unchanged"},"worksheet_inventory":{"mode":"unchanged"}},"policy_decisions":{"external_link_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"formula_integrity":{"evidence":{"after_formula_count":2,"before_formula_count":2},"findings":[{"kind":"formula_changed","message":"worksheet formulas changed between before and after workbooks"}],"status":"failed"},"macro_inventory":{"evidence":{"after_count":0,"after_items":[],"before_count":0,"before_items":[]},"findings":[],"status":"passed"},"package_integrity":{"evidence":{"issue_count":1},"findings":[{"kind":"worksheet_formulas_semantic_drift","message":"worksheet_formulas semantic fingerprint changed after save: before={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (1, 3), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A2)'}, {'kind': 'formula', 'cell': (1, 4), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]} after={'xl/worksheets/sheet1.xml': [{'kind': 'formula', 'cell': (1, 3), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'SUM(A1:A1)'}, {'kind': 'formula', 'cell': (1, 4), 'cached_value': ('n', True, (0, (0,), 0)), 'formula': (('t', None), ('ref', None), ('si', None), ('ca', None), ('bx', None)), 'text': 'A3*2'}]}","part":"worksheet_formulas"}],"status":"failed"},"worksheet_inventory":{"evidence":{"after_count":1,"after_items":["Sheet"],"before_count":1,"before_items":["Sheet"]},"findings":[],"status":"passed"}},"schema_version":1,"status":"failed"} diff --git a/src/spreadsheet_guard/_ooxml_fidelity.py b/src/spreadsheet_guard/_ooxml_fidelity.py index 37b19b5..692b1d8 100644 --- a/src/spreadsheet_guard/_ooxml_fidelity.py +++ b/src/spreadsheet_guard/_ooxml_fidelity.py @@ -17,6 +17,7 @@ class of modify-save regressions where a workbook still opens, but an OOXML import zipfile from collections.abc import Callable, Iterable, Mapping from dataclasses import asdict, dataclass +from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any, TypedDict, TypeVar, cast from xml.etree import ElementTree @@ -26,6 +27,8 @@ class of modify-save regressions where a workbook still opens, but an OOXML REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}" CT_NS = "{http://schemas.openxmlformats.org/package/2006/content-types}" MAIN_NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" +_MAX_WORKSHEET_COLUMN = 16_384 +_MAX_WORKSHEET_ROW = 1_048_576 FEATURE_PART_PREFIXES = { "calc_chain": ("xl/calcChain.xml",), @@ -1093,7 +1096,9 @@ def _cell_coordinate(cell: str) -> tuple[int, int] | None: for char in match.group(1).upper(): column = column * 26 + (ord(char) - ord("A") + 1) row = int(match.group(2)) - return (column, row) if row > 0 else None + if not (1 <= column <= _MAX_WORKSHEET_COLUMN and 1 <= row <= _MAX_WORKSHEET_ROW): + return None + return column, row def _cell_range_bounds(ref: str | None) -> tuple[int, int, int, int] | None: @@ -1114,15 +1119,6 @@ def _cell_range_bounds(ref: str | None) -> tuple[int, int, int, int] | None: ) -def _coordinate_in_range( - coordinate: tuple[int, int], - bounds: tuple[int, int, int, int], -) -> bool: - column, row = coordinate - first_column, first_row, last_column, last_row = bounds - return first_column <= column <= last_column and first_row <= row <= last_row - - def _cell_col_index(cell: str) -> int | None: coordinate = _cell_coordinate(cell) return coordinate[0] if coordinate is not None else None @@ -1699,6 +1695,138 @@ def _timeline_fingerprint( return out +def _worksheet_formula_cells( + root: ElementTree.Element, +) -> list[ + tuple[ + ElementTree.Element, + ElementTree.Element | None, + tuple[int, int], + ] +]: + cells: list[ + tuple[ + ElementTree.Element, + ElementTree.Element | None, + tuple[int, int], + ] + ] = [] + previous_row = 0 + for row in _nodes_by_local(root, "row"): + row_reference = _attr(row, "r") + row_number = ( + int(row_reference) + if row_reference is not None + and row_reference.isdigit() + and 1 <= int(row_reference) <= _MAX_WORKSHEET_ROW + else previous_row + 1 + ) + previous_column = 0 + for cell in _children_by_local(row, "c"): + cell_reference = _attr(cell, "r") + coordinate = ( + _cell_coordinate(cell_reference) if cell_reference is not None else None + ) + if coordinate is None: + coordinate = previous_column + 1, row_number + elif row_reference is None: + row_number = coordinate[1] + previous_column = coordinate[0] + cells.append( + ( + cell, + _first_child_by_local(cell, "f"), + coordinate, + ) + ) + previous_row = max(previous_row, row_number) + return cells + + +def _array_result_coordinates( + cells: list[ + tuple[ + ElementTree.Element, + ElementTree.Element | None, + tuple[int, int], + ] + ], + array_ranges: list[tuple[int, int, int, int]], +) -> set[tuple[int, int]]: + events: dict[int, list[tuple[int, int]]] = {} + for first_column, first_row, last_column, last_row in array_ranges: + events.setdefault(first_row, []).extend( + ((first_column, 1), (last_column + 1, -1)) + ) + if last_row < _MAX_WORKSHEET_ROW: + events.setdefault(last_row + 1, []).extend( + ((first_column, -1), (last_column + 1, 1)) + ) + if not events: + return set() + + event_rows = sorted(events) + event_index = 0 + coverage_tree = [0] * (_MAX_WORKSHEET_COLUMN + 2) + + def update(column: int, delta: int) -> None: + while column < len(coverage_tree): + coverage_tree[column] += delta + column += column & -column + + def coverage(column: int) -> int: + value = 0 + while column > 0: + value += coverage_tree[column] + column -= column & -column + return value + + array_results: set[tuple[int, int]] = set() + for _, formula, coordinate in sorted( + cells, + key=lambda item: (item[2][1], item[2][0]), + ): + column, row = coordinate + while event_index < len(event_rows) and event_rows[event_index] <= row: + for event_column, delta in events[event_rows[event_index]]: + update(event_column, delta) + event_index += 1 + if formula is None and coverage(column) > 0: + array_results.add(coordinate) + return array_results + + +def _cached_formula_value_fingerprint( + cell: ElementTree.Element, +) -> tuple[str, bool, object]: + effective_type = _attr(cell, "t") or "n" + cached_value = _first_child_by_local(cell, "v") + if cached_value is None: + return effective_type, False, None + value = cached_value.text + if effective_type != "n" or value is None: + return effective_type, True, value + try: + number = Decimal(value.strip()) + except InvalidOperation: + return effective_type, True, value + if not number.is_finite(): + return effective_type, True, value + sign, digits, exponent = number.as_tuple() + if not any(digits): + return effective_type, True, (0, (0,), 0) + canonical_digits = list(digits) + canonical_exponent = cast(int, exponent) + while canonical_digits[-1] == 0: + canonical_digits.pop() + canonical_exponent += 1 + return ( + effective_type, + True, + (sign, tuple(canonical_digits), canonical_exponent), + ) + + def _worksheet_formula_fingerprint( archive: zipfile.ZipFile, parts: set[str] ) -> dict[str, list[object]]: @@ -1709,39 +1837,24 @@ def _worksheet_formula_fingerprint( root = _read_xml_or_none(archive, part) if root is None: continue - cells: list[tuple[ElementTree.Element, ElementTree.Element | None]] = [] - array_ranges: list[tuple[int, int, int, int]] = [] - for cell in root.iter(): - if _local_name(cell.tag) != "c": - continue - formula = _first_child_by_local(cell, "f") - cells.append((cell, formula)) - if formula is not None and _attr(formula, "t") == "array": - bounds = _cell_range_bounds(_attr(formula, "ref")) - if bounds is not None: - array_ranges.append(bounds) + cells = _worksheet_formula_cells(root) + array_ranges = [ + bounds + for _, formula, _ in cells + if formula is not None + and _attr(formula, "t") == "array" + and (bounds := _cell_range_bounds(_attr(formula, "ref"))) is not None + ] + array_results = _array_result_coordinates(cells, array_ranges) formulas: list[object] = [] - for cell, formula in cells: - if formula is None: - cell_reference = _attr(cell, "r") - coordinate = ( - _cell_coordinate(cell_reference) - if cell_reference is not None - else None - ) - if coordinate is None or not any( - _coordinate_in_range(coordinate, bounds) for bounds in array_ranges - ): - continue - cached_value = _first_child_by_local(cell, "v") + for cell, formula, coordinate in cells: + if formula is None and coordinate not in array_results: + continue fingerprint: dict[str, object] = { "kind": "formula" if formula is not None else "array_result", - "cell": _stable_attrs(cell, ("r", "t")), - "cached_value": ( - cached_value is not None, - cached_value.text if cached_value is not None else None, - ), + "cell": coordinate, + "cached_value": _cached_formula_value_fingerprint(cell), } if formula is not None: fingerprint["formula"] = _stable_attrs( diff --git a/tests/test_standalone_engine.py b/tests/test_standalone_engine.py index a954031..a32d631 100644 --- a/tests/test_standalone_engine.py +++ b/tests/test_standalone_engine.py @@ -110,15 +110,15 @@ def test_bundled_engine_rejects_a_non_anchor_array_cached_result_change( ), ( b'SUM(A1:A2)30' - b'60' + b'60' ), ) _replace_archive_member( before, after, "xl/worksheets/sheet1.xml", - b'60', - b'999', + b"60", + b"999", ) outcome = guard_workbooks( @@ -135,6 +135,39 @@ def test_bundled_engine_rejects_a_non_anchor_array_cached_result_change( } +def test_bundled_engine_normalizes_equivalent_numeric_formula_caches( + tmp_path: Path, +) -> None: + before = tmp_path / "numeric-before.xlsx" + after = tmp_path / "numeric-after.xlsx" + original = b'SUM(A1:A2)0' + for destination, formula_cell in ( + ( + before, + b'SUM(A1:A2)1.0', + ), + ( + after, + b'SUM(A1:A2)1', + ), + ): + _replace_archive_member( + PROOF_DIR / "before.xlsx", + destination, + "xl/worksheets/sheet1.xml", + original, + formula_cell, + ) + + outcome = guard_workbooks( + before, + after, + tmp_path / "numeric-cached-result-report.json", + ) + + assert outcome.status == "passed" + + def test_bundled_engine_preserves_cached_string_whitespace(tmp_path: Path) -> None: before = tmp_path / "string-before.xlsx" after = tmp_path / "string-after.xlsx" From cf1c59b1e647ec67c3f36ef3fd94dbbacee5352a Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:30:18 -0700 Subject: [PATCH 5/7] ci: add hosted package checks --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e63b9ae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + package: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.14" + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --frozen --extra dev --python ${{ matrix.python-version }} + - run: uv run --no-sync --python ${{ matrix.python-version }} ruff check src tests + - run: uv run --no-sync --python ${{ matrix.python-version }} mypy src tests + - run: uv run --no-sync --python ${{ matrix.python-version }} pytest -q + - run: uv build From c9dac47ab6ee4faab9befb3b3a9946610e9cad19 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:33:05 -0700 Subject: [PATCH 6/7] fix: cover data-table cached results --- src/spreadsheet_guard/_ooxml_fidelity.py | 22 +++++++++---------- tests/test_standalone_engine.py | 28 +++++++++++++++++++----- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/spreadsheet_guard/_ooxml_fidelity.py b/src/spreadsheet_guard/_ooxml_fidelity.py index 692b1d8..a8d08c6 100644 --- a/src/spreadsheet_guard/_ooxml_fidelity.py +++ b/src/spreadsheet_guard/_ooxml_fidelity.py @@ -1743,7 +1743,7 @@ def _worksheet_formula_cells( return cells -def _array_result_coordinates( +def _range_formula_result_coordinates( cells: list[ tuple[ ElementTree.Element, @@ -1751,10 +1751,10 @@ def _array_result_coordinates( tuple[int, int], ] ], - array_ranges: list[tuple[int, int, int, int]], + ranges: list[tuple[int, int, int, int]], ) -> set[tuple[int, int]]: events: dict[int, list[tuple[int, int]]] = {} - for first_column, first_row, last_column, last_row in array_ranges: + for first_column, first_row, last_column, last_row in ranges: events.setdefault(first_row, []).extend( ((first_column, 1), (last_column + 1, -1)) ) @@ -1781,7 +1781,7 @@ def coverage(column: int) -> int: column -= column & -column return value - array_results: set[tuple[int, int]] = set() + results: set[tuple[int, int]] = set() for _, formula, coordinate in sorted( cells, key=lambda item: (item[2][1], item[2][0]), @@ -1792,8 +1792,8 @@ def coverage(column: int) -> int: update(event_column, delta) event_index += 1 if formula is None and coverage(column) > 0: - array_results.add(coordinate) - return array_results + results.add(coordinate) + return results def _cached_formula_value_fingerprint( @@ -1838,21 +1838,21 @@ def _worksheet_formula_fingerprint( if root is None: continue cells = _worksheet_formula_cells(root) - array_ranges = [ + range_formula_ranges = [ bounds for _, formula, _ in cells if formula is not None - and _attr(formula, "t") == "array" + and _attr(formula, "t") in {"array", "dataTable"} and (bounds := _cell_range_bounds(_attr(formula, "ref"))) is not None ] - array_results = _array_result_coordinates(cells, array_ranges) + range_results = _range_formula_result_coordinates(cells, range_formula_ranges) formulas: list[object] = [] for cell, formula, coordinate in cells: - if formula is None and coordinate not in array_results: + if formula is None and coordinate not in range_results: continue fingerprint: dict[str, object] = { - "kind": "formula" if formula is not None else "array_result", + "kind": "formula" if formula is not None else "range_result", "cell": coordinate, "cached_value": _cached_formula_value_fingerprint(cell), } diff --git a/tests/test_standalone_engine.py b/tests/test_standalone_engine.py index a32d631..7994e52 100644 --- a/tests/test_standalone_engine.py +++ b/tests/test_standalone_engine.py @@ -95,11 +95,26 @@ def test_bundled_engine_rejects_a_cached_formula_result_change( } -def test_bundled_engine_rejects_a_non_anchor_array_cached_result_change( +@pytest.mark.parametrize( + ("case_name", "anchor_formula"), + [ + ( + "array", + b'SUM(A1:A2)', + ), + ( + "data-table", + b'', + ), + ], +) +def test_bundled_engine_rejects_a_non_anchor_range_formula_cache_change( tmp_path: Path, + case_name: str, + anchor_formula: bytes, ) -> None: - before = tmp_path / "array-before.xlsx" - after = tmp_path / "array-after.xlsx" + before = tmp_path / f"{case_name}-before.xlsx" + after = tmp_path / f"{case_name}-after.xlsx" _replace_archive_member( PROOF_DIR / "before.xlsx", before, @@ -109,8 +124,9 @@ def test_bundled_engine_rejects_a_non_anchor_array_cached_result_change( b'A3*20' ), ( - b'SUM(A1:A2)30' - b'60' + b'' + + anchor_formula + + b'3060' ), ) _replace_archive_member( @@ -124,7 +140,7 @@ def test_bundled_engine_rejects_a_non_anchor_array_cached_result_change( outcome = guard_workbooks( before, after, - tmp_path / "array-cached-result-report.json", + tmp_path / f"{case_name}-cached-result-report.json", ) assert outcome.status == "failed" From dcd22691257f79f2ee648ef0c6503fb9cce2d95b Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:39:01 -0700 Subject: [PATCH 7/7] fix: stream ordinary formula fingerprints --- src/spreadsheet_guard/_ooxml_fidelity.py | 64 +++++++++++------------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/src/spreadsheet_guard/_ooxml_fidelity.py b/src/spreadsheet_guard/_ooxml_fidelity.py index a8d08c6..c07c656 100644 --- a/src/spreadsheet_guard/_ooxml_fidelity.py +++ b/src/spreadsheet_guard/_ooxml_fidelity.py @@ -24,6 +24,11 @@ class of modify-save regressions where a workbook still opens, but an OOXML AuditValue = TypeVar("AuditValue") FingerprintValue = TypeVar("FingerprintValue", bound=Mapping[str, object]) +_WorksheetFormulaCell = tuple[ + ElementTree.Element, + ElementTree.Element | None, + tuple[int, int], +] REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}" CT_NS = "{http://schemas.openxmlformats.org/package/2006/content-types}" MAIN_NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" @@ -1697,22 +1702,11 @@ def _timeline_fingerprint( def _worksheet_formula_cells( root: ElementTree.Element, -) -> list[ - tuple[ - ElementTree.Element, - ElementTree.Element | None, - tuple[int, int], - ] -]: - cells: list[ - tuple[ - ElementTree.Element, - ElementTree.Element | None, - tuple[int, int], - ] - ] = [] +) -> Iterable[_WorksheetFormulaCell]: previous_row = 0 - for row in _nodes_by_local(root, "row"): + for row in root.iter(): + if _local_name(row.tag) != "row": + continue row_reference = _attr(row, "r") row_number = ( int(row_reference) @@ -1722,7 +1716,9 @@ def _worksheet_formula_cells( else previous_row + 1 ) previous_column = 0 - for cell in _children_by_local(row, "c"): + for cell in row: + if _local_name(cell.tag) != "c": + continue cell_reference = _attr(cell, "r") coordinate = ( _cell_coordinate(cell_reference) if cell_reference is not None else None @@ -1732,25 +1728,16 @@ def _worksheet_formula_cells( elif row_reference is None: row_number = coordinate[1] previous_column = coordinate[0] - cells.append( - ( - cell, - _first_child_by_local(cell, "f"), - coordinate, - ) + yield ( + cell, + _first_child_by_local(cell, "f"), + coordinate, ) previous_row = max(previous_row, row_number) - return cells def _range_formula_result_coordinates( - cells: list[ - tuple[ - ElementTree.Element, - ElementTree.Element | None, - tuple[int, int], - ] - ], + cells: list[_WorksheetFormulaCell], ranges: list[tuple[int, int, int, int]], ) -> set[tuple[int, int]]: events: dict[int, list[tuple[int, int]]] = {} @@ -1837,15 +1824,24 @@ def _worksheet_formula_fingerprint( root = _read_xml_or_none(archive, part) if root is None: continue - cells = _worksheet_formula_cells(root) range_formula_ranges = [ bounds - for _, formula, _ in cells - if formula is not None + for formula in root.iter() + if _local_name(formula.tag) == "f" and _attr(formula, "t") in {"array", "dataTable"} and (bounds := _cell_range_bounds(_attr(formula, "ref"))) is not None ] - range_results = _range_formula_result_coordinates(cells, range_formula_ranges) + cells: Iterable[_WorksheetFormulaCell] + range_results: set[tuple[int, int]] = set() + if range_formula_ranges: + materialized_cells = list(_worksheet_formula_cells(root)) + range_results = _range_formula_result_coordinates( + materialized_cells, + range_formula_ranges, + ) + cells = materialized_cells + else: + cells = _worksheet_formula_cells(root) formulas: list[object] = [] for cell, formula, coordinate in cells: