diff --git a/docs/changelog.d/999-report-title-callback-safety.md b/docs/changelog.d/999-report-title-callback-safety.md new file mode 100644 index 000000000..42e18ba40 --- /dev/null +++ b/docs/changelog.d/999-report-title-callback-safety.md @@ -0,0 +1,5 @@ +# Harden generic diagnostics report title callback boundary + +## Fixed + +- Reject caller-defined `str` subclasses at the public generic diagnostics-report title boundary before truth-value or HTML-escaping callbacks can run, while preserving `None` and an empty exact built-in string as requests for the report-type default title. diff --git a/python/fast_mlsirm/report.py b/python/fast_mlsirm/report.py index b8627f83d..450123245 100644 --- a/python/fast_mlsirm/report.py +++ b/python/fast_mlsirm/report.py @@ -38,7 +38,9 @@ def render_diagnostics_report( raise ValueError("diagnostics JSON must contain an object") report_type = _diagnostics_type(payload) - resolved_title = title or _default_title(report_type) + if title is not None and type(title) is not str: + raise ValueError("title must be a string or None") + resolved_title = title if title else _default_title(report_type) out = Path(output_path) if out.suffix.lower() != ".html": @@ -217,9 +219,7 @@ def _render_dimensionality_report(payload: dict[str, Any]) -> list[str]: def _metric_section(heading: str, metrics: dict[str, Any]) -> str | None: """Render a labelled metric-card grid, or ``None`` when there are no metrics.""" if len(metrics) > MAX_REPORT_METRICS: - raise ValueError( - f"report metrics exceed the {MAX_REPORT_METRICS}-entry limit" - ) + raise ValueError(f"report metrics exceed the {MAX_REPORT_METRICS}-entry limit") cards = [] for key, value in metrics.items(): @@ -443,13 +443,9 @@ def _rows_from_columnar(section: Any) -> list[dict[str, Any]]: if row_count == 0: return [] if row_count > MAX_REPORT_ROWS: - raise ValueError( - f"report table rows exceed the {MAX_REPORT_ROWS}-row limit" - ) + raise ValueError(f"report table rows exceed the {MAX_REPORT_ROWS}-row limit") if row_count * len(columns) > MAX_REPORT_CELLS: - raise ValueError( - f"report table cells exceed the {MAX_REPORT_CELLS}-cell limit" - ) + raise ValueError(f"report table cells exceed the {MAX_REPORT_CELLS}-cell limit") rows = [] for index in range(row_count): @@ -463,9 +459,7 @@ def _rows_from_columnar(section: Any) -> list[dict[str, Any]]: def _validate_report_rows(rows: list[dict[str, Any]]) -> None: """Reject row-oriented diagnostics that exceed report rendering budgets.""" if len(rows) > MAX_REPORT_ROWS: - raise ValueError( - f"report table rows exceed the {MAX_REPORT_ROWS}-row limit" - ) + raise ValueError(f"report table rows exceed the {MAX_REPORT_ROWS}-row limit") columns: set[str] = set() cell_count = 0 @@ -646,7 +640,7 @@ def _css() -> str: padding: 32px 20px 48px; } -main:focus { +main:focus:not(:focus-visible) { outline: none; } @@ -836,7 +830,7 @@ def _css() -> str: border-radius: 8px; } -.table-wrap:focus { +.table-wrap:focus:not(:focus-visible) { outline: none; } @@ -925,6 +919,11 @@ def _css() -> str: color: var(--teal); } +.exact-values > summary:focus:not(:focus-visible), +.export-block > summary:focus:not(:focus-visible) { + outline: none; +} + .exact-values > summary:focus-visible, .export-block > summary:focus-visible { outline: 3px solid var(--teal); @@ -951,7 +950,7 @@ def _css() -> str: white-space: pre; } -.export-block pre:focus { +.export-block pre:focus:not(:focus-visible) { outline: none; } diff --git a/tests/test_report.py b/tests/test_report.py index 0fee77e2b..1727a21b3 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -7,6 +7,7 @@ def test_render_fit_diagnostics_report_has_sections(tmp_path): + """Render populated fit diagnostics with the expected report sections.""" source = tmp_path / "fit_diagnostics.json" out = tmp_path / "report.html" source.write_text( @@ -48,6 +49,7 @@ def test_render_fit_diagnostics_report_has_sections(tmp_path): def test_render_dimensionality_report_has_best_candidate(tmp_path): + """Render dimensionality diagnostics with the selected candidate.""" source = tmp_path / "dimension_diagnostics.json" out = tmp_path / "dimensions.html" source.write_text( @@ -74,6 +76,7 @@ def test_render_dimensionality_report_has_best_candidate(tmp_path): def test_render_dimensionality_report_summarizes_empty_candidates(tmp_path): + """Summarize dimensionality diagnostics when no candidates are available.""" source = tmp_path / "dimension_diagnostics.json" out = tmp_path / "dimensions.html" source.write_text( @@ -97,6 +100,7 @@ def test_render_dimensionality_report_summarizes_empty_candidates(tmp_path): def test_render_report_summarizes_empty_metric_sections(tmp_path): + """Summarize an empty metric section without rendering an empty panel.""" source = tmp_path / "fit_diagnostics.json" out = tmp_path / "report.html" source.write_text( @@ -123,6 +127,7 @@ def test_render_report_summarizes_empty_metric_sections(tmp_path): def test_render_dimensionality_report_summarizes_empty_best_candidate(tmp_path): + """Summarize a dimensionality report whose best candidate has no metrics.""" source = tmp_path / "dimension_diagnostics.json" out = tmp_path / "dimensions.html" source.write_text( @@ -150,6 +155,7 @@ def test_render_dimensionality_report_summarizes_empty_best_candidate(tmp_path): def test_render_coverage_omits_empty_rendered_tables_column(tmp_path): + """Omit the rendered-table coverage column when no table is present.""" source = tmp_path / "dimension_diagnostics.json" out = tmp_path / "dimensions.html" source.write_text( @@ -173,6 +179,7 @@ def test_render_coverage_omits_empty_rendered_tables_column(tmp_path): def test_render_table_section_omits_empty_chart_placeholder(tmp_path): + """Avoid an empty chart placeholder when rows have no numeric values.""" source = tmp_path / "fit_diagnostics.json" out = tmp_path / "report.html" source.write_text( @@ -194,6 +201,7 @@ def test_render_table_section_omits_empty_chart_placeholder(tmp_path): def test_render_table_section_charts_later_numeric_rows(tmp_path): + """Include a later numeric row when earlier rows are not chartable.""" source = tmp_path / "fit_diagnostics.json" out = tmp_path / "report.html" item_ids = list(range(13)) @@ -217,6 +225,7 @@ def test_render_table_section_charts_later_numeric_rows(tmp_path): def test_render_report_rejects_unknown_payload(tmp_path): + """Reject diagnostics JSON that has no supported report payload.""" source = tmp_path / "unknown.json" out = tmp_path / "report.html" source.write_text(json.dumps({"status": "ok"}), encoding="utf-8") @@ -226,6 +235,7 @@ def test_render_report_rejects_unknown_payload(tmp_path): def test_render_report_rejects_excessive_json_nesting(tmp_path): + """Reject JSON that exceeds the configured nesting safety limit.""" source = tmp_path / "deep.json" out = tmp_path / "report.html" depth = MAX_JSON_NESTING_DEPTH + 1 @@ -238,6 +248,7 @@ def test_render_report_rejects_excessive_json_nesting(tmp_path): def test_render_report_requires_html_output(tmp_path): + """Reject output paths that do not use the HTML extension.""" source = tmp_path / "fit_diagnostics.json" out = tmp_path / "report.txt" source.write_text(json.dumps({"model_fit": {}}), encoding="utf-8") @@ -247,6 +258,7 @@ def test_render_report_requires_html_output(tmp_path): def test_render_table_region_has_keyboard_focus_style(tmp_path): + """Keep keyboard focus styling while suppressing mouse-click outlines.""" source = tmp_path / "dimension_diagnostics.json" out = tmp_path / "dimensions.html" source.write_text( @@ -265,7 +277,7 @@ def test_render_table_region_has_keyboard_focus_style(tmp_path): assert 'aria-label="Candidate Comparison diagnostics table"' in html assert 'tabindex="0"' in html assert ".table-wrap:focus-visible" in html - assert ".table-wrap:focus {" in html + assert ".table-wrap:focus:not(:focus-visible) {" in html assert "tbody tr:hover" in html assert '