cadence-diff 0.1.0__py3-none-any.whl

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 (78) hide show
  1. cadence_diff-0.1.0.dist-info/METADATA +245 -0
  2. cadence_diff-0.1.0.dist-info/RECORD +78 -0
  3. cadence_diff-0.1.0.dist-info/WHEEL +4 -0
  4. cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
  5. cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
  6. qc_tool/__init__.py +3 -0
  7. qc_tool/__main__.py +6 -0
  8. qc_tool/attestation.py +209 -0
  9. qc_tool/availability.py +273 -0
  10. qc_tool/cli.py +769 -0
  11. qc_tool/config/__init__.py +1 -0
  12. qc_tool/config/lint.py +480 -0
  13. qc_tool/config/profile.py +207 -0
  14. qc_tool/coverage.py +35 -0
  15. qc_tool/crosscheck/__init__.py +1 -0
  16. qc_tool/crosscheck/numbers.py +146 -0
  17. qc_tool/crosscheck/package.py +154 -0
  18. qc_tool/crosscheck/trace.py +374 -0
  19. qc_tool/engine.py +978 -0
  20. qc_tool/excel/__init__.py +1 -0
  21. qc_tool/excel/align.py +452 -0
  22. qc_tool/excel/charts.py +931 -0
  23. qc_tool/excel/context.py +106 -0
  24. qc_tool/excel/controls.py +266 -0
  25. qc_tool/excel/dependency.py +252 -0
  26. qc_tool/excel/diff_structure.py +326 -0
  27. qc_tool/excel/diff_values.py +327 -0
  28. qc_tool/excel/formulas.py +516 -0
  29. qc_tool/excel/interaction.py +548 -0
  30. qc_tool/excel/periods.py +117 -0
  31. qc_tool/excel/preflight.py +615 -0
  32. qc_tool/excel/references.py +436 -0
  33. qc_tool/excel/regions.py +201 -0
  34. qc_tool/findings.py +220 -0
  35. qc_tool/fingerprint.py +369 -0
  36. qc_tool/fingerprint.schema.json +15 -0
  37. qc_tool/history/__init__.py +1 -0
  38. qc_tool/history/store.py +287 -0
  39. qc_tool/io/__init__.py +1 -0
  40. qc_tool/io/decrypt.py +64 -0
  41. qc_tool/io/excel_formula.py +256 -0
  42. qc_tool/io/excel_formula_worker.py +280 -0
  43. qc_tool/io/formula_enrichment.py +95 -0
  44. qc_tool/io/libreoffice_formula.py +232 -0
  45. qc_tool/io/loader.py +973 -0
  46. qc_tool/io/model.py +356 -0
  47. qc_tool/io/ooxml_chart.py +530 -0
  48. qc_tool/io/ooxml_interaction.py +400 -0
  49. qc_tool/io/ooxml_worksheet.py +405 -0
  50. qc_tool/io/xlsb_formula.py +313 -0
  51. qc_tool/package-sanitize.schema.json +31 -0
  52. qc_tool/package_sanitize.py +255 -0
  53. qc_tool/ppt/__init__.py +1 -0
  54. qc_tool/ppt/chart_xml.py +422 -0
  55. qc_tool/ppt/diff.py +120 -0
  56. qc_tool/ppt/element_diff.py +878 -0
  57. qc_tool/ppt/element_match.py +317 -0
  58. qc_tool/ppt/extract.py +212 -0
  59. qc_tool/ppt/match.py +137 -0
  60. qc_tool/ppt/model.py +132 -0
  61. qc_tool/ppt/preflight.py +307 -0
  62. qc_tool/privacy.py +392 -0
  63. qc_tool/progress.py +78 -0
  64. qc_tool/report/__init__.py +1 -0
  65. qc_tool/report/excel_report.py +205 -0
  66. qc_tool/report/findings.schema.json +32 -0
  67. qc_tool/report/html_report.py +38 -0
  68. qc_tool/report/json_report.py +55 -0
  69. qc_tool/report/templates/report.html.j2 +117 -0
  70. qc_tool/sanitize.py +598 -0
  71. qc_tool/security.py +34 -0
  72. qc_tool/server_config.py +86 -0
  73. qc_tool/triage/__init__.py +1 -0
  74. qc_tool/triage/rules.py +211 -0
  75. qc_tool/ui/__init__.py +1 -0
  76. qc_tool/ui/app.py +1261 -0
  77. qc_tool/ui/guide.py +452 -0
  78. qc_tool/ui/theme.py +505 -0
qc_tool/attestation.py ADDED
@@ -0,0 +1,209 @@
1
+ """Signed QC attestation bundles and tamper verification."""
2
+
3
+ import datetime as dt
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import secrets
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from qc_tool import __version__
15
+ from qc_tool.config.profile import DeliverableProfile
16
+ from qc_tool.engine import QCRunResult
17
+ from qc_tool.report.json_report import result_payload
18
+ from qc_tool.security import private_directory, private_file
19
+
20
+ _KEY_BYTES = 32
21
+
22
+
23
+ class AttestationIssue(BaseModel):
24
+ code: str
25
+ message: str
26
+
27
+
28
+ class AttestationVerification(BaseModel):
29
+ valid: bool
30
+ key_id: str = ""
31
+ issues: list[AttestationIssue] = Field(default_factory=list)
32
+
33
+ def add(self, code: str, message: str) -> None:
34
+ self.valid = False
35
+ self.issues.append(AttestationIssue(code=code, message=message))
36
+
37
+
38
+ def _sha256_bytes(data: bytes) -> str:
39
+ return hashlib.sha256(data).hexdigest()
40
+
41
+
42
+ def _canonical(payload: dict) -> bytes:
43
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
44
+
45
+
46
+ def _key_id(key: bytes) -> str:
47
+ return hashlib.sha256(key).hexdigest()[:16]
48
+
49
+
50
+ def load_or_create_attestation_key(data_dir: Path) -> tuple[Path, bytes]:
51
+ private_directory(data_dir)
52
+ key_path = data_dir / "attestation.key"
53
+ if key_path.exists():
54
+ key = key_path.read_bytes()
55
+ else:
56
+ key = secrets.token_bytes(_KEY_BYTES)
57
+ key_path.write_bytes(key)
58
+ private_file(key_path)
59
+ if len(key) < _KEY_BYTES:
60
+ raise ValueError(f"attestation key {key_path} must contain at least {_KEY_BYTES} bytes")
61
+ if os.name == "posix" and key_path.stat().st_mode & 0o077:
62
+ raise ValueError(f"attestation key {key_path} must not be group/world accessible")
63
+ return key_path, key
64
+
65
+
66
+ def load_attestation_key(path: Path) -> bytes:
67
+ key = path.read_bytes()
68
+ if len(key) < _KEY_BYTES:
69
+ raise ValueError(f"attestation key {path} must contain at least {_KEY_BYTES} bytes")
70
+ if os.name == "posix" and path.stat().st_mode & 0o077:
71
+ raise ValueError(f"attestation key {path} must not be group/world accessible")
72
+ return key
73
+
74
+
75
+ def create_attestation(
76
+ output: Path,
77
+ *,
78
+ result: QCRunResult,
79
+ profile: DeliverableProfile,
80
+ input_files: dict[str, Path],
81
+ report_paths: dict[str, Path],
82
+ key: bytes,
83
+ ) -> Path:
84
+ """Create a private signed bundle containing evidence and report members."""
85
+ profile_bytes = json.dumps(
86
+ profile.model_dump(mode="json", by_alias=True), indent=2
87
+ ).encode("utf-8")
88
+ findings_bytes = json.dumps(
89
+ result_payload(result, include_context=True), indent=2
90
+ ).encode("utf-8")
91
+ members: dict[str, bytes] = {
92
+ "evidence/profile.json": profile_bytes,
93
+ "evidence/findings.json": findings_bytes,
94
+ }
95
+ for kind, path in sorted(report_paths.items()):
96
+ members[f"reports/qc_report.{path.suffix.lstrip('.') or kind}"] = path.read_bytes()
97
+
98
+ member_manifest = {
99
+ name: {"sha256": _sha256_bytes(data), "size": len(data)}
100
+ for name, data in sorted(members.items())
101
+ }
102
+ inputs = {
103
+ role: {
104
+ "name": path.name,
105
+ "sha256": _sha256_bytes(path.read_bytes()),
106
+ "size": path.stat().st_size,
107
+ }
108
+ for role, path in sorted(input_files.items())
109
+ }
110
+ analyst_decisions = [
111
+ {
112
+ "finding_id": finding.finding_id,
113
+ "severity": finding.severity.value if finding.severity else None,
114
+ "severity_overridden": finding.severity_overridden,
115
+ "comment": finding.analyst_comment,
116
+ "waiver_reason": finding.waiver_reason,
117
+ "waiver_expires": finding.waiver_expires,
118
+ }
119
+ for finding in result.findings
120
+ if finding.severity_overridden or finding.analyst_comment or finding.waiver_reason
121
+ ]
122
+ unsigned = {
123
+ "schema_version": 1,
124
+ "generated_at": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
125
+ "tool": {"distribution": "cadence-diff", "version": __version__},
126
+ "run": {
127
+ "mode": result.mode.value,
128
+ "profile": result.profile_name,
129
+ "counts": {severity.value: count for severity, count in result.counts.items()},
130
+ "coverage": [item.model_dump(mode="json") for item in result.coverage],
131
+ "mapping_coverage": (
132
+ result.mapping_coverage.model_dump(mode="json")
133
+ if result.mapping_coverage is not None
134
+ else None
135
+ ),
136
+ "verified_crosschecks": result.verified_crosschecks,
137
+ },
138
+ "profile_sha256": _sha256_bytes(profile_bytes),
139
+ "inputs": inputs,
140
+ "members": member_manifest,
141
+ "waivers": [item.model_dump(mode="json") for item in profile.waivers],
142
+ "analyst_decisions": analyst_decisions,
143
+ }
144
+ signature = hmac.new(key, _canonical(unsigned), hashlib.sha256).hexdigest()
145
+ manifest = {
146
+ **unsigned,
147
+ "signature": {
148
+ "algorithm": "HMAC-SHA256",
149
+ "key_id": _key_id(key),
150
+ "value": signature,
151
+ },
152
+ }
153
+ private_directory(output.parent)
154
+ with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
155
+ archive.writestr("manifest.json", json.dumps(manifest, indent=2))
156
+ for name, data in members.items():
157
+ archive.writestr(name, data)
158
+ private_file(output)
159
+ return output
160
+
161
+
162
+ def verify_attestation(path: Path, *, key: bytes) -> AttestationVerification:
163
+ result = AttestationVerification(valid=True, key_id=_key_id(key))
164
+ try:
165
+ archive = zipfile.ZipFile(path)
166
+ except zipfile.BadZipFile:
167
+ result.add("bad-zip", "attestation is not a valid ZIP archive")
168
+ return result
169
+ with archive:
170
+ names = set(archive.namelist())
171
+ if "manifest.json" not in names:
172
+ result.add("missing-manifest", "manifest.json is missing")
173
+ return result
174
+ try:
175
+ manifest = json.loads(archive.read("manifest.json"))
176
+ except (json.JSONDecodeError, UnicodeDecodeError):
177
+ result.add("invalid-manifest", "manifest.json is not valid JSON")
178
+ return result
179
+ signature = manifest.pop("signature", None)
180
+ if not isinstance(signature, dict):
181
+ result.add("missing-signature", "manifest signature is missing")
182
+ return result
183
+ if signature.get("algorithm") != "HMAC-SHA256":
184
+ result.add("signature-algorithm", "unsupported signature algorithm")
185
+ if signature.get("key_id") != _key_id(key):
186
+ result.add("key-id", "attestation was signed with a different key")
187
+ expected = hmac.new(key, _canonical(manifest), hashlib.sha256).hexdigest()
188
+ if not hmac.compare_digest(str(signature.get("value", "")), expected):
189
+ result.add("signature", "manifest signature does not verify")
190
+ members = manifest.get("members")
191
+ if not isinstance(members, dict):
192
+ result.add("member-manifest", "member hash manifest is invalid")
193
+ return result
194
+ expected_names = {"manifest.json", *members}
195
+ unexpected = names - expected_names
196
+ missing = expected_names - names
197
+ if unexpected:
198
+ result.add("unexpected-members", f"unexpected members: {sorted(unexpected)}")
199
+ if missing:
200
+ result.add("missing-members", f"missing members: {sorted(missing)}")
201
+ for name, metadata in members.items():
202
+ if name not in names or not isinstance(metadata, dict):
203
+ continue
204
+ data = archive.read(name)
205
+ if metadata.get("sha256") != _sha256_bytes(data):
206
+ result.add("member-hash", f"hash mismatch for {name}")
207
+ if metadata.get("size") != len(data):
208
+ result.add("member-size", f"size mismatch for {name}")
209
+ return result
@@ -0,0 +1,273 @@
1
+ """Profile-driven blank availability boundaries shared by Excel and PowerPoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openpyxl.utils.cell import range_boundaries
6
+
7
+ from qc_tool.config.profile import DeliverableProfile, PptProfile, SheetProfile
8
+ from qc_tool.coverage import CoverageItem, CoverageState
9
+ from qc_tool.excel.periods import is_period_after, parse_period
10
+ from qc_tool.io.model import SheetSnapshot, WorkbookSnapshot
11
+ from qc_tool.ppt.model import DeckSnapshot
12
+
13
+
14
+ def _bounds(cell_range: str) -> tuple[int, int, int, int] | None:
15
+ try:
16
+ min_col, min_row, max_col, max_row = range_boundaries(cell_range)
17
+ except ValueError:
18
+ return None
19
+ if min_col is None or min_row is None or max_col is None or max_row is None:
20
+ return None
21
+ return min_col, min_row, max_col, max_row
22
+
23
+
24
+ def cell_in_ranges(row: int, column: int, ranges: list[str]) -> bool:
25
+ """Whether a cell belongs to any configured A1 range."""
26
+ for cell_range in ranges:
27
+ bounds = _bounds(cell_range)
28
+ if bounds is None:
29
+ continue
30
+ min_col, min_row, max_col, max_row = bounds
31
+ if min_row <= row <= max_row and min_col <= column <= max_col:
32
+ return True
33
+ return False
34
+
35
+
36
+ def _excel_period_value(
37
+ sheet: SheetSnapshot,
38
+ target_range: str,
39
+ period_range: str,
40
+ row: int,
41
+ column: int,
42
+ ) -> object | None:
43
+ target = _bounds(target_range)
44
+ periods = _bounds(period_range)
45
+ if target is None or periods is None:
46
+ return None
47
+ target_min_col, target_min_row, target_max_col, target_max_row = target
48
+ if not (
49
+ target_min_row <= row <= target_max_row
50
+ and target_min_col <= column <= target_max_col
51
+ ):
52
+ return None
53
+ period_min_col, period_min_row, period_max_col, period_max_row = periods
54
+ period_width = period_max_col - period_min_col + 1
55
+ period_height = period_max_row - period_min_row + 1
56
+ target_width = target_max_col - target_min_col + 1
57
+ target_height = target_max_row - target_min_row + 1
58
+ if period_width == period_height == 1:
59
+ period_row, period_column = period_min_row, period_min_col
60
+ elif period_height == 1 and period_width == target_width:
61
+ period_row = period_min_row
62
+ period_column = period_min_col + (column - target_min_col)
63
+ elif period_width == 1 and period_height == target_height:
64
+ period_row = period_min_row + (row - target_min_row)
65
+ period_column = period_min_col
66
+ else:
67
+ return None
68
+ cell = sheet.cells.get((period_row, period_column))
69
+ return None if cell is None else cell.value
70
+
71
+
72
+ def excel_blank_allowed(
73
+ sheet: SheetSnapshot,
74
+ profile: SheetProfile | None,
75
+ row: int,
76
+ column: int,
77
+ ) -> bool:
78
+ """Whether every applicable resolved Excel rule permits this blank."""
79
+ if profile is None:
80
+ return False
81
+ decisions: list[bool] = []
82
+ for rule in profile.availability_rules:
83
+ period_value = _excel_period_value(
84
+ sheet,
85
+ rule.cell_range,
86
+ rule.period_range,
87
+ row,
88
+ column,
89
+ )
90
+ if period_value is None:
91
+ continue
92
+ period = parse_period(period_value)
93
+ required = parse_period(rule.required_through)
94
+ if period is None or required is None or period.kind != required.kind:
95
+ decisions.append(False)
96
+ continue
97
+ decisions.append(
98
+ bool(rule.allow_blank_after and is_period_after(period, required))
99
+ )
100
+ return bool(decisions) and all(decisions)
101
+
102
+
103
+ def _matches(value: str, configured: str) -> bool:
104
+ return not configured or value.casefold() == configured.casefold()
105
+
106
+
107
+ def ppt_blank_allowed(
108
+ profile: PptProfile,
109
+ *,
110
+ slide: str,
111
+ scope: str,
112
+ element: str,
113
+ series: str,
114
+ period_label: str,
115
+ ) -> bool:
116
+ """Whether every matching PowerPoint rule permits this future blank."""
117
+ period = parse_period(period_label)
118
+ if period is None:
119
+ return False
120
+ decisions: list[bool] = []
121
+ for rule in profile.availability_rules:
122
+ if (
123
+ rule.scope != scope
124
+ or rule.slide.casefold() != slide.casefold()
125
+ or not _matches(element, rule.element)
126
+ or not _matches(series, rule.series or "")
127
+ ):
128
+ continue
129
+ required = parse_period(rule.required_through)
130
+ if required is None or required.kind != period.kind:
131
+ decisions.append(False)
132
+ continue
133
+ decisions.append(
134
+ bool(rule.allow_blank_after and is_period_after(period, required))
135
+ )
136
+ return bool(decisions) and all(decisions)
137
+
138
+
139
+ def availability_coverage(
140
+ *,
141
+ artifact: str,
142
+ rule_count: int,
143
+ issues: list[str] | None = None,
144
+ ) -> CoverageItem:
145
+ """Disclose configured boundaries or the strict no-inference default."""
146
+ issues = issues or []
147
+ return CoverageItem(
148
+ check_id=f"{artifact}-availability",
149
+ label="Availability boundaries",
150
+ artifact=artifact,
151
+ state=CoverageState.DEGRADED if issues else CoverageState.CHECKED,
152
+ detail=(
153
+ (
154
+ f"{rule_count} availability rules configured; "
155
+ f"{len(issues)} unresolved: {'; '.join(issues)}"
156
+ )
157
+ if issues
158
+ else f"{rule_count} explicit availability rules applied"
159
+ if rule_count
160
+ else "No availability rules configured; strict blank requirements applied"
161
+ ),
162
+ )
163
+
164
+
165
+ def excel_availability_issues(
166
+ workbook: WorkbookSnapshot,
167
+ profile: DeliverableProfile,
168
+ ) -> list[str]:
169
+ """Runtime resolution failures for Excel availability rules."""
170
+ issues: list[str] = []
171
+ for sheet_name, sheet_profile in profile.excel.sheets.items():
172
+ if sheet_name in profile.excel.ignore_sheets or sheet_profile.ignore:
173
+ continue
174
+ if not sheet_profile.availability_rules:
175
+ continue
176
+ try:
177
+ sheet = workbook.sheet(sheet_name)
178
+ except KeyError:
179
+ issues.append(f"sheet {sheet_name!r} not found")
180
+ continue
181
+ for index, rule in enumerate(sheet_profile.availability_rules):
182
+ label = rule.name or str(index)
183
+ target = _bounds(rule.cell_range)
184
+ periods = _bounds(rule.period_range)
185
+ required = parse_period(rule.required_through)
186
+ if target is None or periods is None or required is None:
187
+ issues.append(f"{sheet_name}/{label} has invalid ranges or period")
188
+ continue
189
+ target_min_col, target_min_row, target_max_col, target_max_row = target
190
+ period_min_col, period_min_row, period_max_col, period_max_row = periods
191
+ target_width = target_max_col - target_min_col + 1
192
+ target_height = target_max_row - target_min_row + 1
193
+ period_width = period_max_col - period_min_col + 1
194
+ period_height = period_max_row - period_min_row + 1
195
+ aligned = (
196
+ period_width == period_height == 1
197
+ or (period_height == 1 and period_width == target_width)
198
+ or (period_width == 1 and period_height == target_height)
199
+ )
200
+ if not aligned:
201
+ issues.append(f"{sheet_name}/{label} period and target ranges misalign")
202
+ continue
203
+ observed = []
204
+ for row in range(period_min_row, period_max_row + 1):
205
+ for column in range(period_min_col, period_max_col + 1):
206
+ cell = sheet.cells.get((row, column))
207
+ if cell is not None and cell.value not in {None, ""}:
208
+ observed.append(parse_period(cell.value))
209
+ if not observed:
210
+ issues.append(f"{sheet_name}/{label} resolves no period labels")
211
+ elif any(
212
+ period is None or period.kind != required.kind
213
+ for period in observed
214
+ ):
215
+ issues.append(
216
+ f"{sheet_name}/{label} period labels do not match "
217
+ f"{required.kind!r} required_through"
218
+ )
219
+ return sorted(set(issues))
220
+
221
+
222
+ def ppt_availability_issues(
223
+ deck: DeckSnapshot,
224
+ profile: PptProfile,
225
+ ) -> list[str]:
226
+ """Runtime resolution failures for PowerPoint availability rules."""
227
+ issues: list[str] = []
228
+ slides = {slide.display_name.casefold(): slide for slide in deck.slides}
229
+ for index, rule in enumerate(profile.availability_rules):
230
+ label = rule.name or str(index)
231
+ if parse_period(rule.required_through) is None:
232
+ issues.append(f"{label} has invalid required_through")
233
+ continue
234
+ slide = slides.get(rule.slide.casefold())
235
+ if slide is None:
236
+ issues.append(f"{label} slide {rule.slide!r} not found")
237
+ continue
238
+ if rule.scope == "table":
239
+ candidates = [
240
+ (
241
+ table.name or f"table[{table.source_index}]",
242
+ {
243
+ row[0].casefold()
244
+ for row in table.rows[1:]
245
+ if row and row[0]
246
+ },
247
+ )
248
+ for table in slide.tables
249
+ ]
250
+ else:
251
+ candidates = [
252
+ (
253
+ chart.title or chart.name or f"chart[{chart.source_index}]",
254
+ {
255
+ series.name.casefold()
256
+ for series in chart.all_series
257
+ if series.name
258
+ },
259
+ )
260
+ for chart in slide.charts
261
+ ]
262
+ matching = [
263
+ (element, names)
264
+ for element, names in candidates
265
+ if not rule.element or element.casefold() == rule.element.casefold()
266
+ ]
267
+ if rule.element and not matching:
268
+ issues.append(f"{label} element {rule.element!r} not found")
269
+ elif rule.series and not any(
270
+ rule.series.casefold() in names for _, names in matching
271
+ ):
272
+ issues.append(f"{label} series/row {rule.series!r} not found")
273
+ return sorted(set(issues))