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/cli.py ADDED
@@ -0,0 +1,769 @@
1
+ """Command-line interface: serve (default), run, sanitize, lint.
2
+
3
+ `cadence-diff` with no subcommand serves the web UI. The legacy `qc-tool`
4
+ entry point remains supported.
5
+ `qc-tool run` executes headless QC with CI-style exit codes, `sanitize`
6
+ supports local numeric scrambling or strict verified redaction,
7
+ `fingerprint` emits structural-only JSON, and `lint` validates a profile.
8
+
9
+ Exit codes: 0 ok · 1 usage/runtime error · 2 findings at/above the
10
+ --fail-on threshold (run) or lint errors (lint).
11
+ """
12
+
13
+ import argparse
14
+ import getpass
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from platformdirs import user_data_dir
20
+
21
+ from qc_tool import __version__
22
+ from qc_tool.security import private_directory
23
+
24
+ _SUBCOMMANDS = {
25
+ "serve",
26
+ "run",
27
+ "sanitize",
28
+ "sanitize-package",
29
+ "verify-sanitized",
30
+ "fingerprint",
31
+ "verify-attestation",
32
+ "network",
33
+ "lint",
34
+ }
35
+ _MODE_ALIASES = {
36
+ "cycle": "cycle_comparison",
37
+ "preflight": "current_file_preflight",
38
+ "package": "final_package",
39
+ }
40
+ _ROLES = ("baseline_excel", "current_excel", "baseline_ppt", "current_ppt")
41
+
42
+
43
+ def _program_name() -> str:
44
+ executable = Path(sys.argv[0]).name.casefold()
45
+ return "qc-tool" if executable.startswith("qc-tool") else "cadence-diff"
46
+
47
+
48
+ def default_data_dir() -> Path:
49
+ """Per-user writable location for uploads, profiles, history, reports."""
50
+ return Path(user_data_dir("qc-tool", appauthor=False))
51
+
52
+
53
+ # --- serve -------------------------------------------------------------------
54
+
55
+
56
+ def build_parser() -> argparse.ArgumentParser:
57
+ parser = argparse.ArgumentParser(
58
+ prog=_program_name(),
59
+ description=(
60
+ "Local, read-only QC for cadence Excel/PowerPoint deliverables: "
61
+ "current-file preflight, baseline/current cycle comparison, and "
62
+ "final-package reconciliation. Serves a web UI on 127.0.0.1. "
63
+ "Subcommands: run (headless QC), sanitize (shareable scrambled "
64
+ "or verified-redacted copies), fingerprint (structural-only JSON), "
65
+ "lint (profile validation)."
66
+ ),
67
+ )
68
+ parser.add_argument(
69
+ "--port", type=int, default=8080, help="localhost port (default: 8080)"
70
+ )
71
+ parser.add_argument(
72
+ "--data-dir",
73
+ type=Path,
74
+ default=None,
75
+ help=(
76
+ "directory for uploads, profiles, run history, and reports "
77
+ f"(default: {default_data_dir()})"
78
+ ),
79
+ )
80
+ parser.add_argument(
81
+ "--version", action="version", version=f"%(prog)s {__version__}"
82
+ )
83
+ parser.add_argument(
84
+ "--network",
85
+ choices=["local", "lan"],
86
+ default=None,
87
+ help="process exposure override; LAN requires --expose-for",
88
+ )
89
+ parser.add_argument(
90
+ "--expose-for",
91
+ type=int,
92
+ default=None,
93
+ metavar="MINUTES",
94
+ help="temporary LAN duration (1-1440 minutes)",
95
+ )
96
+ return parser
97
+
98
+
99
+ def _cmd_serve(args: list[str]) -> int:
100
+ ns = build_parser().parse_args(args)
101
+ data_dir = ns.data_dir or default_data_dir()
102
+ data_dir.mkdir(parents=True, exist_ok=True)
103
+ from qc_tool.server_config import (
104
+ load_server_config,
105
+ local_config,
106
+ save_server_config,
107
+ temporary_lan_config,
108
+ )
109
+
110
+ if ns.network == "lan":
111
+ if ns.expose_for is None:
112
+ raise ValueError("--network lan requires --expose-for MINUTES")
113
+ config = temporary_lan_config(ns.expose_for)
114
+ save_server_config(data_dir, config)
115
+ elif ns.network == "local":
116
+ if ns.expose_for is not None:
117
+ raise ValueError("--expose-for only applies to --network lan")
118
+ config = local_config()
119
+ save_server_config(data_dir, config)
120
+ else:
121
+ if ns.expose_for is not None:
122
+ raise ValueError("--expose-for requires --network lan")
123
+ config = load_server_config(data_dir)
124
+ from qc_tool.ui.app import run_app # deferred: keep --help/--version instant
125
+
126
+ run_app(
127
+ data_dir,
128
+ port=ns.port,
129
+ host=config.host,
130
+ network_mode=config.network,
131
+ expires_at=config.expires_at,
132
+ )
133
+ return 0
134
+
135
+
136
+ # --- run ---------------------------------------------------------------------
137
+
138
+
139
+ def _run_parser() -> argparse.ArgumentParser:
140
+ parser = argparse.ArgumentParser(
141
+ prog=f"{_program_name()} run",
142
+ description=(
143
+ "Headless QC run. Mode is inferred from the supplied files "
144
+ "(any baseline -> cycle; current Excel+PPT -> package; a single "
145
+ "current file -> preflight) unless --mode is given."
146
+ ),
147
+ )
148
+ for role in _ROLES:
149
+ parser.add_argument(
150
+ f"--{role.replace('_', '-')}", type=Path, default=None, metavar="FILE"
151
+ )
152
+ parser.add_argument(
153
+ "--mode",
154
+ choices=[*_MODE_ALIASES, *_MODE_ALIASES.values()],
155
+ default=None,
156
+ help="cycle | preflight | package (default: inferred)",
157
+ )
158
+ parser.add_argument(
159
+ "--profile",
160
+ default=None,
161
+ metavar="NAME_OR_PATH",
162
+ help="profile name (resolved in <data-dir>/profiles) or a YAML path",
163
+ )
164
+ parser.add_argument("--data-dir", type=Path, default=None)
165
+ parser.add_argument(
166
+ "--json", type=Path, default=None, help="write machine-readable findings JSON"
167
+ )
168
+ parser.add_argument(
169
+ "--json-context",
170
+ action="store_true",
171
+ help=(
172
+ "include raw cell-neighborhood excerpts and mapping candidate values in JSON; "
173
+ "private/sensitive, disabled by default"
174
+ ),
175
+ )
176
+ parser.add_argument(
177
+ "--password",
178
+ action="append",
179
+ default=[],
180
+ metavar="ROLE_OR_FILENAME=PW",
181
+ help=(
182
+ "INSECURE: inline password, visible in process lists and shell history; "
183
+ "prefer --password-env, --password-file, or --password-prompt"
184
+ ),
185
+ )
186
+ parser.add_argument(
187
+ "--password-env",
188
+ action="append",
189
+ default=[],
190
+ metavar="ROLE_OR_FILENAME=ENV_VAR",
191
+ help="read a password from an environment variable (repeatable)",
192
+ )
193
+ parser.add_argument(
194
+ "--password-file",
195
+ action="append",
196
+ default=[],
197
+ metavar="ROLE_OR_FILENAME=PATH",
198
+ help="read a password from a private text file (repeatable)",
199
+ )
200
+ parser.add_argument(
201
+ "--password-prompt",
202
+ action="append",
203
+ default=[],
204
+ metavar="ROLE_OR_FILENAME",
205
+ help="prompt securely for a password (repeatable)",
206
+ )
207
+ parser.add_argument(
208
+ "--fail-on",
209
+ choices=["critical", "warning", "never"],
210
+ default="critical",
211
+ help="exit 2 when findings at/above this severity exist (default: critical)",
212
+ )
213
+ parser.add_argument("--top", type=int, default=10, help="findings to print")
214
+ parser.add_argument(
215
+ "--allow-large-workbooks",
216
+ action="store_true",
217
+ help=(
218
+ "override OOXML workload refusal for this run; requires sufficient "
219
+ "local memory and degrades workload coverage"
220
+ ),
221
+ )
222
+ parser.add_argument(
223
+ "--progress",
224
+ action="store_true",
225
+ help="write phase progress to stderr",
226
+ )
227
+ parser.add_argument(
228
+ "--attestation",
229
+ type=Path,
230
+ default=None,
231
+ help="write an HMAC-signed QC evidence bundle",
232
+ )
233
+ parser.add_argument(
234
+ "--attestation-key-file",
235
+ type=Path,
236
+ default=None,
237
+ help="private HMAC key (default: <data-dir>/attestation.key)",
238
+ )
239
+ return parser
240
+
241
+
242
+ def _infer_mode(files: dict[str, Path]) -> str:
243
+ if "baseline_excel" in files or "baseline_ppt" in files:
244
+ return "cycle_comparison"
245
+ if "current_excel" in files and "current_ppt" in files:
246
+ return "final_package"
247
+ return "current_file_preflight"
248
+
249
+
250
+ def _resolve_profile(spec: str | None, data_dir: Path):
251
+ from qc_tool.config.profile import default_profile, load_profile
252
+ from qc_tool.ui.app import load_profile_by_name
253
+
254
+ if spec is None:
255
+ return default_profile()
256
+ path = Path(spec)
257
+ if path.suffix in {".yaml", ".yml"} or path.exists():
258
+ return load_profile(path)
259
+ return load_profile_by_name(data_dir / "profiles", spec)
260
+
261
+
262
+ def _parse_passwords(pairs: list[str], files: dict[str, Path]) -> dict[str, str]:
263
+ passwords: dict[str, str] = {}
264
+ for pair in pairs:
265
+ key, sep, password = pair.partition("=")
266
+ if not sep:
267
+ raise ValueError(f"--password expects ROLE_OR_FILENAME=PW, got {pair!r}")
268
+ for role in _password_roles(key, files):
269
+ passwords[role] = password
270
+ return passwords
271
+
272
+
273
+ def _password_roles(key: str, files: dict[str, Path]) -> list[str]:
274
+ if key in _ROLES:
275
+ if key not in files:
276
+ raise ValueError(f"password role {key!r} has no supplied file")
277
+ return [key]
278
+ matches = [role for role, path in files.items() if path.name == key]
279
+ if not matches:
280
+ raise ValueError(f"password key {key!r} matches no supplied file or role")
281
+ return matches
282
+
283
+
284
+ def _parse_password_sources(ns: argparse.Namespace, files: dict[str, Path]) -> dict[str, str]:
285
+ passwords = _parse_passwords(ns.password, files)
286
+ for pair in ns.password_env:
287
+ key, sep, variable = pair.partition("=")
288
+ if not sep:
289
+ raise ValueError(f"--password-env expects ROLE_OR_FILENAME=ENV_VAR, got {pair!r}")
290
+ if variable not in os.environ:
291
+ raise ValueError(f"environment variable {variable!r} is not set")
292
+ for role in _password_roles(key, files):
293
+ passwords[role] = os.environ[variable]
294
+ for pair in ns.password_file:
295
+ key, sep, raw_path = pair.partition("=")
296
+ if not sep:
297
+ raise ValueError(f"--password-file expects ROLE_OR_FILENAME=PATH, got {pair!r}")
298
+ path = Path(raw_path)
299
+ password = path.read_text(encoding="utf-8").rstrip("\r\n")
300
+ if not password:
301
+ raise ValueError(f"password file {path} is empty")
302
+ if os.name == "posix" and path.stat().st_mode & 0o077:
303
+ raise ValueError(f"password file {path} must not be group/world accessible")
304
+ for role in _password_roles(key, files):
305
+ passwords[role] = password
306
+ for key in ns.password_prompt:
307
+ password = getpass.getpass(f"Password for {key}: ")
308
+ if not password:
309
+ raise ValueError(f"password for {key!r} is empty")
310
+ for role in _password_roles(key, files):
311
+ passwords[role] = password
312
+ return passwords
313
+
314
+
315
+ def _cmd_run(args: list[str]) -> int:
316
+ ns = _run_parser().parse_args(args)
317
+ files = {
318
+ role: getattr(ns, role)
319
+ for role in _ROLES
320
+ if getattr(ns, role) is not None
321
+ }
322
+ if not files:
323
+ raise ValueError(
324
+ f"{_program_name()} run: supply at least one file (see --help)"
325
+ )
326
+ for role, path in files.items():
327
+ if not path.exists():
328
+ raise ValueError(f"{role}: {path} not found")
329
+
330
+ from qc_tool.coverage import QCRunMode
331
+ from qc_tool.findings import Severity
332
+ from qc_tool.progress import ProgressEvent
333
+ from qc_tool.report.json_report import write_json_report
334
+ from qc_tool.ui.app import perform_run
335
+
336
+ data_dir = ns.data_dir or default_data_dir()
337
+ private_directory(data_dir)
338
+ mode_value = _MODE_ALIASES.get(ns.mode, ns.mode) if ns.mode else _infer_mode(files)
339
+ profile = _resolve_profile(ns.profile, data_dir)
340
+ passwords = _parse_password_sources(ns, files)
341
+
342
+ on_progress = None
343
+ if ns.progress:
344
+
345
+ def print_progress(event: ProgressEvent) -> None:
346
+ label = event.phase.value.replace("_", " ")
347
+ counts = (
348
+ f" ({event.processed}/{event.total})" if event.total else ""
349
+ )
350
+ detail = f" - {event.detail}" if event.detail else ""
351
+ print(f"progress: {label}{counts}{detail}", file=sys.stderr)
352
+
353
+ on_progress = print_progress
354
+
355
+ artifacts = perform_run(
356
+ data_dir,
357
+ files,
358
+ passwords,
359
+ profile,
360
+ mode=QCRunMode(mode_value),
361
+ allow_large_workbooks=ns.allow_large_workbooks,
362
+ on_progress=on_progress,
363
+ )
364
+ result = artifacts.result
365
+
366
+ print(f"mode: {result.mode.value} profile: {result.profile_name}")
367
+ print("files:", " ".join(f"{r}={n}" for r, n in result.files.items()))
368
+ print(
369
+ "counts:",
370
+ " ".join(f"{sev.value}={count}" for sev, count in result.counts.items()),
371
+ )
372
+ if result.coverage:
373
+ states: dict[str, int] = {}
374
+ for item in result.coverage:
375
+ states[item.state.value] = states.get(item.state.value, 0) + 1
376
+ print("coverage:", " ".join(f"{k}={v}" for k, v in sorted(states.items())))
377
+ if result.mapping_coverage is not None:
378
+ mc = result.mapping_coverage
379
+ print(
380
+ f"mapping: eligible={mc.eligible} mapped={mc.mapped} "
381
+ f"verified={mc.verified} mismatched={mc.mismatched} "
382
+ f"unmapped={mc.unmapped}"
383
+ )
384
+ for disclosure in result.disclosures:
385
+ print(f"note: {disclosure}")
386
+ ranked = [
387
+ f
388
+ for f in result.findings
389
+ if f.severity in (Severity.CRITICAL, Severity.WARNING)
390
+ ]
391
+ for finding in ranked[: ns.top]:
392
+ severity = finding.severity.value if finding.severity else "?"
393
+ where = finding.sheet or finding.slide or ""
394
+ print(
395
+ f" {finding.finding_id} {severity:8s} {finding.finding_class.value:24s} "
396
+ f"{where}!{finding.location or finding.element or ''} {finding.message}"
397
+ )
398
+ if len(ranked) > ns.top:
399
+ print(f" ... {len(ranked) - ns.top} more (see reports)")
400
+ print("reports:", " ".join(str(p) for p in artifacts.report_paths.values()))
401
+ if ns.json is not None:
402
+ write_json_report(result, ns.json, include_context=ns.json_context)
403
+ print("json:", ns.json)
404
+ print(f"history: run #{artifacts.run_id} in {data_dir}")
405
+ if ns.attestation is not None:
406
+ from qc_tool.attestation import (
407
+ create_attestation,
408
+ load_attestation_key,
409
+ load_or_create_attestation_key,
410
+ )
411
+
412
+ if ns.attestation_key_file is not None:
413
+ key = load_attestation_key(ns.attestation_key_file)
414
+ else:
415
+ key_path, key = load_or_create_attestation_key(data_dir)
416
+ print(f"attestation key: {key_path}")
417
+ create_attestation(
418
+ ns.attestation,
419
+ result=result,
420
+ profile=profile,
421
+ input_files=files,
422
+ report_paths=artifacts.report_paths,
423
+ key=key,
424
+ )
425
+ print(f"attestation: {ns.attestation}")
426
+
427
+ counts = result.counts
428
+ failing = counts[Severity.CRITICAL]
429
+ if ns.fail_on == "warning":
430
+ failing += counts[Severity.WARNING]
431
+ if ns.fail_on != "never" and failing > 0:
432
+ return 2
433
+ return 0
434
+
435
+
436
+ # --- sanitize -------------------------------------------------------------------
437
+
438
+
439
+ def _sanitize_parser() -> argparse.ArgumentParser:
440
+ parser = argparse.ArgumentParser(
441
+ prog=f"{_program_name()} sanitize",
442
+ description=(
443
+ "Write a structure-preserving copy. Default mode is a local numeric "
444
+ "scrambler and is NOT safe to share. --redact-text enables strict "
445
+ "identifier/content removal and fail-closed privacy verification."
446
+ ),
447
+ )
448
+ parser.add_argument("source", type=Path)
449
+ parser.add_argument("-o", "--output", type=Path, default=None)
450
+ parser.add_argument("--seed", type=int, default=0)
451
+ parser.add_argument(
452
+ "--redact-text",
453
+ action="store_true",
454
+ help="also replace text labels (period labels are always kept)",
455
+ )
456
+ return parser
457
+
458
+
459
+ def _cmd_sanitize(args: list[str]) -> int:
460
+ ns = _sanitize_parser().parse_args(args)
461
+ from qc_tool.sanitize import SanitizeError, sanitize_file
462
+
463
+ try:
464
+ output, stats = sanitize_file(
465
+ ns.source, ns.output, seed=ns.seed, redact_text=ns.redact_text
466
+ )
467
+ except SanitizeError as exc:
468
+ print(f"error: {exc}", file=sys.stderr)
469
+ return 1
470
+ print(f"sanitized -> {output}")
471
+ print(
472
+ f"numbers scrambled: {stats.numbers_scrambled} "
473
+ f"texts redacted: {stats.texts_redacted} "
474
+ f"charts rebuilt: {stats.charts_rebuilt}"
475
+ + (f" charts skipped: {stats.charts_skipped}" if stats.charts_skipped else "")
476
+ )
477
+ for note in stats.notes:
478
+ print(f"note: {note}")
479
+ if not ns.redact_text:
480
+ print(
481
+ "warning: numeric scramble only — NOT privacy verified or safe to share; "
482
+ "use --redact-text for strict verified redaction",
483
+ file=sys.stderr,
484
+ )
485
+ return 0
486
+
487
+
488
+ def _sanitize_package_parser() -> argparse.ArgumentParser:
489
+ parser = argparse.ArgumentParser(
490
+ prog=f"{_program_name()} sanitize-package",
491
+ description=(
492
+ "Strictly sanitize current Excel+PowerPoint together, re-project "
493
+ "confirmed mappings, verify privacy, and write a redaction manifest."
494
+ ),
495
+ )
496
+ parser.add_argument("--excel", type=Path, required=True)
497
+ parser.add_argument("--ppt", type=Path, required=True)
498
+ parser.add_argument("--profile", required=True, metavar="NAME_OR_PATH")
499
+ parser.add_argument("--output-dir", type=Path, required=True)
500
+ parser.add_argument("--data-dir", type=Path, default=None)
501
+ parser.add_argument("--seed", type=int, default=0)
502
+ parser.add_argument(
503
+ "--forbid",
504
+ action="append",
505
+ default=[],
506
+ metavar="TOKEN",
507
+ help="case-insensitive token that must not remain (repeatable)",
508
+ )
509
+ return parser
510
+
511
+
512
+ def _cmd_sanitize_package(args: list[str]) -> int:
513
+ ns = _sanitize_package_parser().parse_args(args)
514
+ from qc_tool.package_sanitize import sanitize_package
515
+ from qc_tool.sanitize import SanitizeError
516
+
517
+ data_dir = ns.data_dir or default_data_dir()
518
+ profile = _resolve_profile(ns.profile, data_dir)
519
+ try:
520
+ manifest = sanitize_package(
521
+ ns.excel,
522
+ ns.ppt,
523
+ ns.output_dir,
524
+ profile,
525
+ seed=ns.seed,
526
+ forbidden_tokens=ns.forbid,
527
+ )
528
+ except SanitizeError as exc:
529
+ print(f"error: {exc}", file=sys.stderr)
530
+ return 1
531
+ print(
532
+ f"package privacy: {'SAFE' if manifest.privacy_safe else 'UNSAFE'} "
533
+ f"mappings verified={manifest.mappings_verified} "
534
+ f"unverifiable={manifest.mappings_unverifiable}"
535
+ )
536
+ print(f"outputs: {ns.output_dir}")
537
+ print(f"manifest: {ns.output_dir / 'redaction-manifest.json'}")
538
+ return 0 if manifest.privacy_safe and not manifest.mappings_unverifiable else 2
539
+
540
+
541
+ def _verify_sanitized_parser() -> argparse.ArgumentParser:
542
+ parser = argparse.ArgumentParser(
543
+ prog=f"{_program_name()} verify-sanitized",
544
+ description="Fail-closed privacy verification for a strict sanitizer output.",
545
+ )
546
+ parser.add_argument("source", type=Path)
547
+ parser.add_argument(
548
+ "--forbid",
549
+ action="append",
550
+ default=[],
551
+ metavar="TOKEN",
552
+ help="case-insensitive token that must not remain (repeatable)",
553
+ )
554
+ parser.add_argument("--json", type=Path, default=None, help="write verification JSON")
555
+ return parser
556
+
557
+
558
+ def _cmd_verify_sanitized(args: list[str]) -> int:
559
+ ns = _verify_sanitized_parser().parse_args(args)
560
+ from qc_tool.privacy import verify_sanitized
561
+ from qc_tool.security import private_directory, private_file
562
+
563
+ report = verify_sanitized(ns.source, forbidden_tokens=ns.forbid)
564
+ for issue in report.issues:
565
+ print(f"unsafe {issue.code:24s} {issue.location}: {issue.message}")
566
+ print(
567
+ f"privacy: {'SAFE' if report.safe else 'UNSAFE'} "
568
+ f"checks={len(report.checks)} issues={len(report.issues)} sha256={report.sha256}"
569
+ )
570
+ if ns.json is not None:
571
+
572
+ private_directory(ns.json.parent)
573
+ ns.json.write_text(report.model_dump_json(indent=2), encoding="utf-8")
574
+ private_file(ns.json)
575
+ return 0 if report.safe else 2
576
+
577
+
578
+ def _fingerprint_parser() -> argparse.ArgumentParser:
579
+ parser = argparse.ArgumentParser(
580
+ prog=f"{_program_name()} fingerprint",
581
+ description=(
582
+ "Write structural-only JSON: dimensions, types, period grammars, "
583
+ "regions, hashed formula patterns, table/chart shapes — no source "
584
+ "values, visible text, paths, formulas, images, or identifiers."
585
+ ),
586
+ )
587
+ parser.add_argument("source", type=Path)
588
+ parser.add_argument("-o", "--output", type=Path, required=True)
589
+ parser.add_argument("--password-env", default=None, metavar="ENV_VAR")
590
+ parser.add_argument("--password-file", type=Path, default=None)
591
+ parser.add_argument("--password-prompt", action="store_true")
592
+ return parser
593
+
594
+
595
+ def _cmd_fingerprint(args: list[str]) -> int:
596
+ ns = _fingerprint_parser().parse_args(args)
597
+ from qc_tool.fingerprint import write_fingerprint
598
+
599
+ password = None
600
+ sources = sum(
601
+ value is not None and value is not False
602
+ for value in (ns.password_env, ns.password_file, ns.password_prompt)
603
+ )
604
+ if sources > 1:
605
+ raise ValueError("choose only one fingerprint password source")
606
+ if ns.password_env:
607
+ if ns.password_env not in os.environ:
608
+ raise ValueError(f"environment variable {ns.password_env!r} is not set")
609
+ password = os.environ[ns.password_env]
610
+ elif ns.password_file:
611
+ if os.name == "posix" and ns.password_file.stat().st_mode & 0o077:
612
+ raise ValueError(
613
+ f"password file {ns.password_file} must not be group/world accessible"
614
+ )
615
+ password = ns.password_file.read_text(encoding="utf-8").rstrip("\r\n")
616
+ elif ns.password_prompt:
617
+ password = getpass.getpass(f"Password for {ns.source.name}: ")
618
+ payload = write_fingerprint(ns.source, ns.output, password=password)
619
+ print(f"fingerprint: {payload['fingerprint_id']} -> {ns.output}")
620
+ return 0
621
+
622
+
623
+ def _verify_attestation_parser() -> argparse.ArgumentParser:
624
+ parser = argparse.ArgumentParser(
625
+ prog=f"{_program_name()} verify-attestation",
626
+ description="Verify an attestation HMAC and every bundled member hash.",
627
+ )
628
+ parser.add_argument("source", type=Path)
629
+ parser.add_argument("--data-dir", type=Path, default=None)
630
+ parser.add_argument("--key-file", type=Path, default=None)
631
+ return parser
632
+
633
+
634
+ def _cmd_verify_attestation(args: list[str]) -> int:
635
+ ns = _verify_attestation_parser().parse_args(args)
636
+ from qc_tool.attestation import (
637
+ load_attestation_key,
638
+ load_or_create_attestation_key,
639
+ verify_attestation,
640
+ )
641
+
642
+ if ns.key_file is not None:
643
+ key = load_attestation_key(ns.key_file)
644
+ else:
645
+ _, key = load_or_create_attestation_key(ns.data_dir or default_data_dir())
646
+ result = verify_attestation(ns.source, key=key)
647
+ for issue in result.issues:
648
+ print(f"invalid {issue.code:20s} {issue.message}")
649
+ print(
650
+ f"attestation: {'VALID' if result.valid else 'INVALID'} "
651
+ f"key_id={result.key_id} issues={len(result.issues)}"
652
+ )
653
+ return 0 if result.valid else 2
654
+
655
+
656
+ def _network_parser() -> argparse.ArgumentParser:
657
+ parser = argparse.ArgumentParser(
658
+ prog=f"{_program_name()} network",
659
+ description=(
660
+ "Persist fail-safe server exposure: local loopback or temporary "
661
+ "unauthenticated LAN/WAN-capable binding."
662
+ ),
663
+ )
664
+ parser.add_argument("action", choices=["status", "local", "lan"])
665
+ parser.add_argument("--minutes", type=int, default=None)
666
+ parser.add_argument("--data-dir", type=Path, default=None)
667
+ return parser
668
+
669
+
670
+ def _cmd_network(args: list[str]) -> int:
671
+ ns = _network_parser().parse_args(args)
672
+ from qc_tool.server_config import (
673
+ load_server_config,
674
+ local_config,
675
+ save_server_config,
676
+ temporary_lan_config,
677
+ )
678
+
679
+ data_dir = ns.data_dir or default_data_dir()
680
+ if ns.action == "local":
681
+ if ns.minutes is not None:
682
+ raise ValueError("--minutes only applies to network lan")
683
+ config = local_config()
684
+ save_server_config(data_dir, config)
685
+ elif ns.action == "lan":
686
+ if ns.minutes is None:
687
+ raise ValueError("network lan requires --minutes (1-1440)")
688
+ config = temporary_lan_config(ns.minutes)
689
+ save_server_config(data_dir, config)
690
+ else:
691
+ if ns.minutes is not None:
692
+ raise ValueError("--minutes does not apply to network status")
693
+ config = load_server_config(data_dir)
694
+ print(f"network: {config.network.value} host={config.host}")
695
+ if config.expires_at is not None:
696
+ print(f"expires: {config.expires_at.isoformat(timespec='seconds')}")
697
+ if config.network.value == "lan":
698
+ print("warning: no built-in authentication or TLS; router forwarding is separate")
699
+ return 0
700
+
701
+
702
+ # --- lint --------------------------------------------------------------------------
703
+
704
+
705
+ def _lint_parser() -> argparse.ArgumentParser:
706
+ parser = argparse.ArgumentParser(
707
+ prog=f"{_program_name()} lint",
708
+ description="Validate a deliverable profile, optionally against real files.",
709
+ )
710
+ parser.add_argument("profile", type=Path, help="profile YAML path")
711
+ parser.add_argument("--against-excel", type=Path, default=None, metavar="XLSX")
712
+ parser.add_argument("--against-ppt", type=Path, default=None, metavar="PPTX")
713
+ return parser
714
+
715
+
716
+ def _cmd_lint(args: list[str]) -> int:
717
+ ns = _lint_parser().parse_args(args)
718
+ from qc_tool.config.lint import lint_profile
719
+ from qc_tool.config.profile import load_profile
720
+
721
+ try:
722
+ profile = load_profile(ns.profile)
723
+ except Exception as exc:
724
+ print(f"error: profile failed to load: {exc}", file=sys.stderr)
725
+ return 2
726
+ workbook = deck = None
727
+ if ns.against_excel is not None:
728
+ from qc_tool.io.loader import load_workbook_snapshot
729
+
730
+ workbook = load_workbook_snapshot(ns.against_excel)
731
+ if ns.against_ppt is not None:
732
+ from qc_tool.ppt.extract import load_deck_snapshot
733
+
734
+ deck = load_deck_snapshot(ns.against_ppt)
735
+
736
+ issues = lint_profile(profile, workbook=workbook, deck=deck)
737
+ for issue in issues:
738
+ print(f"{issue.level:7s} {issue.where}: {issue.message}")
739
+ errors = sum(1 for issue in issues if issue.level == "error")
740
+ warnings = len(issues) - errors
741
+ print(f"lint: {errors} error(s), {warnings} warning(s)")
742
+ return 2 if errors else 0
743
+
744
+
745
+ # --- dispatch -----------------------------------------------------------------------
746
+
747
+
748
+ def main(argv: list[str] | None = None) -> int:
749
+ args = list(sys.argv[1:]) if argv is None else list(argv)
750
+ if args and args[0] in _SUBCOMMANDS:
751
+ command, rest = args[0], args[1:]
752
+ else:
753
+ command, rest = "serve", args # backward compatible bare invocation
754
+ handlers = {
755
+ "serve": _cmd_serve,
756
+ "run": _cmd_run,
757
+ "sanitize": _cmd_sanitize,
758
+ "sanitize-package": _cmd_sanitize_package,
759
+ "verify-sanitized": _cmd_verify_sanitized,
760
+ "fingerprint": _cmd_fingerprint,
761
+ "verify-attestation": _cmd_verify_attestation,
762
+ "network": _cmd_network,
763
+ "lint": _cmd_lint,
764
+ }
765
+ try:
766
+ return handlers[command](rest)
767
+ except (OSError, ValueError) as exc:
768
+ print(f"error: {exc}", file=sys.stderr)
769
+ return 1