install-voc-database.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. """Install only the private VOC database at the existing query skill's default path.
  2. Default is a read-only plan. Exact repeat is a no-op; a different existing target
  3. stops. No skill, environment, gateway, employee state or business record is changed.
  4. """
  5. import argparse
  6. import contextlib
  7. import hashlib
  8. import json
  9. import os
  10. from pathlib import Path
  11. import re
  12. import shutil
  13. import sqlite3
  14. import sys
  15. import tempfile
  16. sys.dont_write_bytecode = True
  17. def digest(path):
  18. with Path(path).open('rb') as stream:
  19. result = hashlib.sha256()
  20. for block in iter(lambda: stream.read(1024 * 1024), b''):
  21. result.update(block)
  22. return result.hexdigest()
  23. def plain(path):
  24. path = Path(path).absolute()
  25. for parent in (path, *path.parents):
  26. if parent.exists() or parent.is_symlink():
  27. if parent.is_symlink() or parent.resolve() != parent:
  28. raise ValueError('SYMLINK_OR_REDIRECTED_PATH')
  29. return path
  30. def package_manifest(package):
  31. entries = {}
  32. for line in (package / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
  33. match = re.fullmatch(r'([a-f0-9]{64}) ([^\\]+)', line)
  34. if not match:
  35. raise ValueError('INVALID_PACKAGE_MANIFEST')
  36. sha, name = match.groups()
  37. rel = Path(name)
  38. if rel.is_absolute() or '..' in rel.parts or name in entries or name == 'FILES.sha256':
  39. raise ValueError('INVALID_PACKAGE_PATH')
  40. path = plain(package / rel)
  41. if not path.is_relative_to(package) or not path.is_file() or digest(path) != sha:
  42. raise ValueError('PACKAGE_HASH_MISMATCH:' + name)
  43. entries[name] = sha
  44. actual = {p.relative_to(package).as_posix() for p in package.rglob('*') if p.is_file()}
  45. for path in package.rglob('*'):
  46. plain(path)
  47. if actual != set(entries) | {'FILES.sha256'}:
  48. raise ValueError('UNEXPECTED_PACKAGE_FILES')
  49. release = json.loads((package / 'DATABASE.json').read_text(encoding='utf-8'))
  50. if release['database']['sha256'] != entries.get('library/library.sqlite'):
  51. raise ValueError('DATABASE_MANIFEST_MISMATCH')
  52. return release, entries
  53. def verify_database(path, expected):
  54. if digest(path) != expected['sha256'] or path.stat().st_size != expected['bytes']:
  55. raise ValueError('DATABASE_HASH_MISMATCH')
  56. with contextlib.closing(sqlite3.connect(path.as_uri() + '?mode=ro&immutable=1', uri=True)) as con:
  57. con.execute('PRAGMA query_only=ON')
  58. meta = dict(con.execute('SELECT key,value FROM meta'))
  59. if meta.get('schema') != expected['schema'] or meta.get('status') != 'ready' or meta.get('generation') != expected['generation']:
  60. raise ValueError('DATABASE_VERSION_OR_GENERATION_MISMATCH')
  61. if meta.get('archive_sha256') != expected['originalArchiveSha256']:
  62. raise ValueError('ORIGINAL_ARCHIVE_MISMATCH')
  63. if con.execute('PRAGMA integrity_check').fetchone()[0] != 'ok' or con.execute('PRAGMA foreign_key_check').fetchall():
  64. raise ValueError('DATABASE_INTEGRITY_FAILED')
  65. counts = {}
  66. for table, count in expected['counts'].items():
  67. if not re.fullmatch('[a-z_]+', table):
  68. raise ValueError('INVALID_TABLE_NAME')
  69. counts[table] = con.execute('SELECT count(*) FROM ' + table).fetchone()[0]
  70. if counts[table] != count:
  71. raise ValueError('DATABASE_COUNT_MISMATCH:' + table)
  72. con.execute('SELECT rowid FROM retrieval_fts WHERE retrieval_fts MATCH ? LIMIT 1', ('拌',)).fetchall()
  73. return counts
  74. def install(package, target_dir, apply=False):
  75. package, target_dir = plain(package), plain(target_dir)
  76. if package == target_dir or package in target_dir.parents or target_dir in package.parents:
  77. raise ValueError('NESTED_PACKAGE_AND_LIBRARY')
  78. release, entries = package_manifest(package)
  79. expected = release['database']
  80. source = package / 'library/library.sqlite'
  81. counts = verify_database(source, expected)
  82. destination = target_dir / 'library.sqlite'
  83. if target_dir.exists():
  84. if not target_dir.is_dir() or {p.name for p in target_dir.iterdir()} != {'library.sqlite'} or not destination.is_file():
  85. raise ValueError('TARGET_EXISTS_DIFFERENT_CONTENT')
  86. plain(destination)
  87. if digest(destination) != expected['sha256']:
  88. raise ValueError('TARGET_EXISTS_DIFFERENT_DATABASE')
  89. verify_database(destination, expected)
  90. operation = 'unchanged'
  91. else:
  92. operation = 'install-database'
  93. result = dict(ok=True, readOnly=not apply, applied=False, operation=operation,
  94. destination=str(destination), databaseGeneration=expected['generation'], databaseSha256=expected['sha256'],
  95. counts=counts, filesChecked=len(entries), databaseChecked=True, databaseWrites=0, databaseRowsChanged=0,
  96. skillFilesModified=False, gatewayModified=False, environmentModified=False, businessRecordsChanged=0,
  97. storageMode='static-snapshot', automaticUpdatesConfigured=False, productionBoardAligned=False,
  98. networkRequests=0, realMessageVerified=False)
  99. if not apply or operation == 'unchanged':
  100. return result
  101. target_dir.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  102. plain(target_dir.parent)
  103. stage = Path(tempfile.mkdtemp(prefix='.ksf-voc-database-', dir=target_dir.parent))
  104. os.chmod(stage, 0o700)
  105. try:
  106. target = stage / 'library.sqlite'
  107. shutil.copyfile(source, target)
  108. os.chmod(target, 0o600)
  109. verify_database(target, expected)
  110. if target_dir.exists() or target_dir.is_symlink():
  111. raise ValueError('CONCURRENT_TARGET_CREATED')
  112. stage.rename(target_dir)
  113. result.update(applied=True, filesystemVerified=True)
  114. finally:
  115. if stage.exists():
  116. shutil.rmtree(stage)
  117. # The staged database was verified before the directory rename; independently
  118. # reread the landed copy without ever opening SQLite in write mode.
  119. verify_database(destination, expected)
  120. return result
  121. if __name__ == '__main__':
  122. parser = argparse.ArgumentParser(description=__doc__)
  123. parser.add_argument('--package', required=True)
  124. parser.add_argument('--target-dir', default='/opt/data/ksf-voc-library')
  125. mode = parser.add_mutually_exclusive_group()
  126. mode.add_argument('--check', action='store_true')
  127. mode.add_argument('--apply', action='store_true')
  128. args = parser.parse_args()
  129. try:
  130. print(json.dumps(install(args.package, args.target_dir, args.apply), ensure_ascii=False, indent=2))
  131. except (ValueError, OSError, KeyError, sqlite3.Error) as error:
  132. print(json.dumps(dict(ok=False, error=dict(code=str(error))), ensure_ascii=False), file=sys.stderr)
  133. raise SystemExit(1)