audit-industry-library.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """Inventory the installed industry corpus without collection or SQL writes.
  2. Python 3.9+, standard library only. Output must be a new directory, outside the
  3. source registry. The optional board request is a read-only summary projection.
  4. """
  5. import argparse
  6. import datetime as dt
  7. import json
  8. from pathlib import Path
  9. import sys
  10. import urllib.parse
  11. import urllib.request
  12. sys.dont_write_bytecode = True
  13. ROOT = Path(__file__).resolve().parents[1]
  14. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  15. import voc_db
  16. import library_audit
  17. def fingerprint(path):
  18. with Path(path).open('rb') as stream:
  19. return voc_db.file_sha256(stream)
  20. def read_board(url):
  21. parsed = urllib.parse.urlsplit(url)
  22. voc_db.require(parsed.scheme in ('https', 'http') and bool(parsed.hostname) and
  23. not parsed.username and not parsed.password and not parsed.query and not parsed.fragment,
  24. 'INVALID_BOARD_URL')
  25. req = urllib.request.Request(url, data=b'{"op":"summary"}',
  26. headers={'Content-Type': 'application/json', 'Accept': 'application/json'}, method='POST')
  27. with urllib.request.urlopen(req, timeout=20) as response:
  28. body = response.read(8 * 1024 * 1024 + 1)
  29. voc_db.require(len(body) <= 8 * 1024 * 1024, 'BOARD_RESPONSE_TOO_LARGE')
  30. return json.loads(body)
  31. def audit(db=None, registry=None, project=None, board_url=None, board_reader=read_board):
  32. voc_db.require(bool(db) != bool(registry), 'ONE_DATABASE_INPUT_REQUIRED')
  33. project = Path(project or ROOT / 'docs/industry-voc-monitoring-20261106').resolve()
  34. scope_bytes = (project / 'scope.json').read_bytes()
  35. tasks_bytes = (project / 'tasks.json').read_bytes()
  36. scope = json.loads(scope_bytes)
  37. tasks = json.loads(tasks_bytes)
  38. voc_db.require(scope.get('schema') == 'ksf.industry-monitoring-scope.v1', 'UNSUPPORTED_PROJECT_SCOPE')
  39. voc_db.require(tasks.get('schema') == 'ksf.industry-monitoring-tasks.v1', 'UNSUPPORTED_TASK_PLAN')
  40. pointer_path = Path(registry).resolve() / 'current.json' if registry else None
  41. pointer_bytes = pointer_path.read_bytes() if pointer_path else None
  42. pointer = json.loads(pointer_bytes) if pointer_bytes else None
  43. if pointer_path:
  44. db = (pointer_path.parent / pointer['file']).resolve()
  45. voc_db.require(db.is_relative_to(pointer_path.parent), 'INVALID_LIBRARY_POINTER')
  46. db = Path(db).resolve()
  47. voc_db.require(db.is_file(), 'DATABASE_NOT_INSTALLED')
  48. voc_db.require(not any(Path(str(db) + suffix).exists() for suffix in ('-wal', '-journal')), 'LIVE_DATABASE_NOT_IMMUTABLE')
  49. before = fingerprint(db)
  50. if pointer and pointer.get('databaseSha256'):
  51. voc_db.require(pointer['databaseSha256'] == before, 'REGISTRY_DATABASE_SHA_MISMATCH')
  52. with voc_db.readonly(db) as c:
  53. inventory = library_audit.inventory(c, scope)
  54. after = fingerprint(db)
  55. voc_db.require(after == before, 'SOURCE_DATABASE_CHANGED_DURING_AUDIT')
  56. if pointer_path:
  57. voc_db.require(pointer_path.read_bytes() == pointer_bytes, 'REGISTRY_CHANGED_DURING_AUDIT')
  58. voc_db.require(pointer.get('generation') == inventory['generation'], 'REGISTRY_GENERATION_MISMATCH')
  59. report = {
  60. 'ok': True, 'schema': library_audit.VERSION, 'generatedAt': dt.datetime.now(dt.timezone.utc).isoformat(),
  61. 'readOnly': True, 'databaseWrites': 0, 'collectionCalls': 0, 'productionWrites': 0,
  62. 'database': {'path': str(db), 'bytes': db.stat().st_size, 'sha256Before': before, 'sha256After': after,
  63. 'unchanged': True, 'registry': str(pointer_path.parent) if pointer_path else None,
  64. 'pointerSha256': voc_db.sha(pointer_bytes) if pointer_bytes else None},
  65. 'project': {'path': str(project), 'scopeSha256': voc_db.sha(scope_bytes), 'tasksSha256': voc_db.sha(tasks_bytes)},
  66. 'inventory': inventory,
  67. 'boardComparison': {'status': 'not_requested', 'browserVerified': False},
  68. }
  69. if board_url:
  70. try:
  71. report['boardComparison'] = {**library_audit.compare_board(report, board_reader(board_url)),
  72. 'url': board_url, 'checkedAt': dt.datetime.now(dt.timezone.utc).isoformat()}
  73. except Exception as error:
  74. report['boardComparison'] = {'status': 'failed', 'url': board_url,
  75. 'error': str(error) if isinstance(error, voc_db.LibraryError) else type(error).__name__,
  76. 'browserVerified': False}
  77. return report, tasks, scope
  78. def write_report(out, report, tasks, scope):
  79. out = Path(out).resolve()
  80. db = Path(report['database']['path']).resolve()
  81. registry = report['database']['registry']
  82. voc_db.require(not out.exists(), 'OUTPUT_DIRECTORY_ALREADY_EXISTS')
  83. voc_db.require(not (registry and out.is_relative_to(Path(registry))), 'OUTPUT_INSIDE_SOURCE_REGISTRY')
  84. voc_db.require(not db.is_relative_to(out), 'OUTPUT_CONTAINS_SOURCE_DATABASE')
  85. # mkdir(exist_ok=False) also prevents a concurrent report from being overwritten.
  86. out.mkdir(parents=True, mode=0o700, exist_ok=False)
  87. data = {
  88. 'audit.json': json.dumps(report, ensure_ascii=False, indent=2) + '\n',
  89. 'tasks.json': json.dumps(tasks, ensure_ascii=False, indent=2) + '\n',
  90. 'scope.json': json.dumps(scope, ensure_ascii=False, indent=2) + '\n',
  91. '监督台账.md': library_audit.markdown_report(report, tasks),
  92. '监督台账.html': library_audit.html_report(report, tasks),
  93. }
  94. for name, text in data.items():
  95. path = out / name
  96. with path.open('x', encoding='utf-8', newline='\n') as stream:
  97. stream.write(text)
  98. path.chmod(0o600)
  99. manifest = ''.join(fingerprint(out / name) + ' ' + name + '\n' for name in sorted(data))
  100. with (out / 'FILES.sha256').open('x', encoding='utf-8', newline='\n') as stream:
  101. stream.write(manifest)
  102. (out / 'FILES.sha256').chmod(0o600)
  103. return out
  104. def main():
  105. parser = argparse.ArgumentParser(description=__doc__)
  106. source = parser.add_mutually_exclusive_group(required=True)
  107. source.add_argument('--db')
  108. source.add_argument('--registry')
  109. parser.add_argument('--project', default=str(ROOT / 'docs/industry-voc-monitoring-20261106'))
  110. parser.add_argument('--compare-board', help='Public read-only summary URL, no credentials/query string')
  111. parser.add_argument('--out', required=True, help='New non-existing report directory')
  112. args = parser.parse_args()
  113. try:
  114. voc_db.require(not Path(args.out).exists(), 'OUTPUT_DIRECTORY_ALREADY_EXISTS')
  115. report, tasks, scope = audit(args.db, args.registry, args.project, args.compare_board)
  116. out = write_report(args.out, report, tasks, scope)
  117. board = report['boardComparison']
  118. comparison_passed = board.get('status') == 'compared' and board['generationMatches'] and board['allCountsMatch']
  119. print(json.dumps({'ok': True, 'report': str(out / '监督台账.html'),
  120. 'generation': report['inventory']['generation'], 'counts': report['inventory']['counts'],
  121. 'sourceUnchanged': True, 'databaseWrites': 0, 'collectionCalls': 0,
  122. 'boardComparisonPassed': comparison_passed if args.compare_board else None,
  123. 'physicalShaMatches': board.get('physicalShaMatches')}, ensure_ascii=False))
  124. return 0 if not args.compare_board or comparison_passed else 2
  125. except Exception as error:
  126. print(json.dumps({'ok': False, 'error': {'code': str(error) if isinstance(error, voc_db.LibraryError)
  127. else type(error).__name__}, 'databaseWrites': 0}, ensure_ascii=False), file=sys.stderr)
  128. return 1
  129. if __name__ == '__main__':
  130. raise SystemExit(main())