#!/usr/bin/env python3 """Check a staged installer bundle against bundle-lock.json. Prints one line per problem and exits non-zero if there are any. Exits 0 only when the bundle's third-party payload is EXACTLY what the lock describes: no missing file, no unexpected extra file, no changed content. Why this exists alongside bundle-lock.ps1, which does the same job: - bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server, where PowerShell is the only thing guaranteed to be present - Python is not installed until stage 2, and verifying the payload after running part of it would defeat the purpose. - This file lets the Linux builder (build-installer.sh) do the same check without adding pwsh as a build dependency. The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against the same fixtures and fails if they disagree. Usage: verify_bundle_lock.py """ import hashlib import json import os import sys # Must match $script:BundlePayloads in bundle-lock.ps1. PAYLOADS = [ ('wheels', True, 'Python wheels for the offline install'), ('python', True, 'the Python installer'), ('httpplatformhandler', True, 'the IIS module that launches waitress'), ('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'), ('mysql', False, 'MySQL, for the bundled-database option'), ] def digest(path): sha = hashlib.sha256() with open(path, 'rb') as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b''): sha.update(chunk) return sha.hexdigest() def payload_files(directory): """Every file under the directory, keyed by forward-slashed relative path.""" found = {} if not os.path.isdir(directory): return found for root, _dirs, files in os.walk(directory): for name in files: full = os.path.join(root, name) rel = os.path.relpath(full, directory).replace(os.sep, '/') found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)} return found def verify(bundle_root, lock): problems = [] locked = lock.get('payloads') if not locked: return ['bundle-lock.json has no "payloads" section'] for name, required, what in PAYLOADS: directory = os.path.join(bundle_root, name) present = os.path.isdir(directory) if name not in locked: if present: problems.append( '%s/ is present but is not in bundle-lock.json - regenerate the lock' % name) elif required: problems.append( '%s/ is required but is in neither the bundle nor the lock' % name) continue if not present: if required or locked[name].get('required'): problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what)) continue expected = locked[name].get('files', {}) actual = payload_files(directory) for rel, want in sorted(expected.items()): got = actual.get(rel) if got is None: problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel)) elif got['sha256'] != want['sha256']: problems.append( '%s/%s does NOT match the lock (expected sha256 %s..., got %s...)' % (name, rel, want['sha256'][:12], got['sha256'][:12])) elif int(got['size']) != int(want['size']): # Impossible for a matching sha256, so the lock was hand-edited. problems.append( '%s/%s size disagrees with the lock - the lock has been edited by hand' % (name, rel)) for rel in sorted(actual): if rel not in expected: problems.append( '%s/%s is in the bundle but NOT in the lock (unexpected extra file)' % (name, rel)) return problems def main(): if len(sys.argv) != 3: sys.exit('usage: verify_bundle_lock.py ') bundle_root, lock_path = sys.argv[1], sys.argv[2] if not os.path.exists(lock_path): print('no bundle-lock.json at %s' % lock_path) return 1 with open(lock_path) as fh: lock = json.load(fh) problems = verify(bundle_root, lock) for problem in problems: print(problem) return 1 if problems else 0 if __name__ == '__main__': sys.exit(main())