install-wecom-tool-result-hotfix.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. """Exact two-file patch. Default is read-only; never restarts the gateway."""
  2. import argparse
  3. import ast
  4. import hashlib
  5. import json
  6. import os
  7. from pathlib import Path
  8. import stat
  9. import tempfile
  10. ROOT = Path(__file__).resolve().parent.parent
  11. TARGET = Path('/opt/hermes/plugins/platforms/wecom')
  12. BEFORE = {
  13. 'workshop_registration.py': 'ecef06cacb4e05cc84ee3256fd6841f0da2bfa4d1cf69f39a2e6f38b05461d3e',
  14. 'identity_bridge.py': 'fa26f0c8918650a3d68869026ce547fa7ed0e50974b60819c954105ba7b16e01',
  15. }
  16. class PatchError(Exception):
  17. pass
  18. def digest(data):
  19. return hashlib.sha256(data).hexdigest()
  20. def plain(path):
  21. if not path.is_absolute() or path.resolve() != path:
  22. raise PatchError('UNSAFE_PATCH_PATH')
  23. for p in (path, *path.parents):
  24. if p.is_symlink():
  25. raise PatchError('UNSAFE_PATCH_PATH')
  26. info = path.lstat()
  27. if not stat.S_ISREG(info.st_mode):
  28. raise PatchError('NOT_PLAIN_FILE')
  29. return info
  30. def atomic(path, content, mode, uid, gid):
  31. fd, name = tempfile.mkstemp(prefix='.' + path.name + '.hotfix-', dir=path.parent)
  32. try:
  33. with os.fdopen(fd, 'wb') as f:
  34. f.write(content)
  35. f.flush()
  36. os.fsync(f.fileno())
  37. os.fchmod(f.fileno(), mode)
  38. if hasattr(os, 'fchown'):
  39. os.fchown(f.fileno(), uid, gid)
  40. os.replace(name, path)
  41. finally:
  42. if os.path.exists(name):
  43. os.unlink(name)
  44. def install(source_root=ROOT, target=TARGET, apply=False, backup_dir=None):
  45. manifest = json.loads((source_root / 'HOTFIX.json').read_text(encoding='utf-8'))
  46. if manifest.get('version') != 'ksf.wecom-tool-result-hotfix.v1' or set(manifest.get('files', {})) != set(BEFORE):
  47. raise PatchError('INVALID_HOTFIX_MANIFEST')
  48. entries = []
  49. for name, old_hash in BEFORE.items():
  50. origin = source_root / 'integrations/wecom-identity' / name
  51. plain(origin)
  52. data = origin.read_bytes()
  53. expected = manifest['files'][name]
  54. if expected['beforeSha256'] != old_hash or digest(data) != expected['sha256']:
  55. raise PatchError('PACKAGE_HASH_MISMATCH')
  56. ast.parse(data.decode('utf-8'))
  57. dest = target / name
  58. info = plain(dest)
  59. prior = dest.read_bytes()
  60. if digest(prior) not in (old_hash, digest(data)):
  61. raise PatchError('UNKNOWN_TARGET_VERSION')
  62. entries.append({'name': name, 'path': dest, 'data': data, 'prior': prior, 'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid})
  63. journal = {'ok': True, 'readOnly': not apply, 'operation': 'tool-result-hotfix-plan' if not apply else 'tool-result-hotfix-installed',
  64. 'gatewayRestarted': False, 'businessWrites': 0, 'identityStateChanged': False,
  65. 'files': [{'name': e['name'], 'beforeSha256': digest(e['prior']), 'afterSha256': digest(e['data']),
  66. 'action': 'unchanged' if e['prior'] == e['data'] else 'update'} for e in entries]}
  67. if not apply:
  68. return journal
  69. changed = [e for e in entries if e['prior'] != e['data']]
  70. if not changed:
  71. return journal
  72. if backup_dir is None or not backup_dir.is_absolute() or backup_dir.exists() or backup_dir.parent.resolve() != backup_dir.parent or backup_dir.parent == target or backup_dir.parent == source_root:
  73. raise PatchError('NEW_ABSOLUTE_BACKUP_REQUIRED')
  74. if target == backup_dir or target in backup_dir.parents or backup_dir in target.parents:
  75. raise PatchError('BACKUP_TARGET_OVERLAP')
  76. for parent in backup_dir.parents:
  77. if parent.is_symlink():
  78. raise PatchError('UNSAFE_PATCH_PATH')
  79. backup_dir.mkdir(mode=0o700)
  80. journal['backup'] = str(backup_dir)
  81. for e in changed:
  82. with (backup_dir / e['name']).open('xb') as f:
  83. f.write(e['prior'])
  84. os.chmod(backup_dir / e['name'], 0o600)
  85. with (backup_dir / 'before.json').open('x', encoding='utf-8') as f:
  86. json.dump([{'name': e['name'], 'sha256': digest(e['prior']), 'uid': e['uid'], 'gid': e['gid'], 'mode': e['mode']} for e in changed], f)
  87. os.chmod(backup_dir / 'before.json', 0o600)
  88. done = []
  89. try:
  90. for e in changed:
  91. info = plain(e['path'])
  92. if e['path'].read_bytes() != e['prior'] or (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) != (e['uid'], e['gid'], e['mode']):
  93. raise PatchError('TARGET_CHANGED_CONCURRENTLY')
  94. done.append(e)
  95. atomic(e['path'], e['data'], e['mode'], e['uid'], e['gid'])
  96. for e in entries:
  97. if e['path'].read_bytes() != e['data']:
  98. raise PatchError('PATCH_READBACK_MISMATCH')
  99. return journal
  100. except Exception:
  101. complete = True
  102. for e in reversed(done):
  103. try:
  104. plain(e['path'])
  105. current = e['path'].read_bytes()
  106. if current == e['prior']:
  107. continue
  108. if current != e['data']:
  109. complete = False
  110. continue
  111. atomic(e['path'], e['prior'], e['mode'], e['uid'], e['gid'])
  112. except Exception:
  113. complete = False
  114. error = PatchError('HOTFIX_INSTALL_FAILED')
  115. error.receipt = {**journal, 'ok': False, 'rollbackComplete': complete}
  116. raise error from None
  117. if __name__ == '__main__':
  118. try:
  119. parser = argparse.ArgumentParser()
  120. parser.add_argument('--apply', action='store_true')
  121. parser.add_argument('--backup-dir', type=Path)
  122. args = parser.parse_args()
  123. if os.name != 'posix' or os.geteuid() != 0:
  124. raise PatchError('LINUX_ROOT_REQUIRED')
  125. print(json.dumps(install(apply=args.apply, backup_dir=args.backup_dir), ensure_ascii=False, indent=2))
  126. except Exception as exc:
  127. code = str(exc) if isinstance(exc, PatchError) else 'HOTFIX_STOPPED'
  128. print(json.dumps(getattr(exc, 'receipt', {'ok': False, 'error': {'code': code}, 'gatewayRestarted': False}), ensure_ascii=False))
  129. raise SystemExit(1) from None