The lock records what IS in the wheelhouse, not what the application NEEDS, so an incomplete wheelhouse was locked, blessed and shipped - and only failed on an air-gapped server. That is not hypothetical. Assembling the wheelhouse anywhere other than Windows silently omits colorama, a win32-only dependency of click, because pip evaluates environment markers against the machine doing the downloading rather than the machine being targeted. The bundle built here was short exactly that one wheel. Both verifiers now cross-check wheels/ against the staged requirements.txt, ignoring markers, since a requirement guarded by sys_platform == 'win32' is precisely the one that must be present. Names are normalised to PEP 427 wheel form, so mysql-connector-python matches mysql_connector_python. bundle-lock.json is the first real lock: 42 files, cp314/win_amd64 - 39 wheels, Python 3.14.6, HttpPlatformHandler 1.2 and URL Rewrite. MySQL is absent and optional; a site choosing the bundled-database option adds it and re-locks. The naming gate now skips the installer's build output. It contains a staged copy of the application plus a second SPA build under dist-subpath, which --exclude-dir=dist does not match, so a staged bundle failed the gate on vendored minified JS nobody in this repository wrote.
232 lines
9.0 KiB
Python
232 lines
9.0 KiB
Python
"""The installer bundle lock: fail-closed behaviour, and parity between the two
|
|
implementations that read it.
|
|
|
|
There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
|
|
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
|
|
the Linux builder without adding pwsh as a build dependency. Two implementations
|
|
of one rule drift. These tests run BOTH against the same fixtures and fail if
|
|
they disagree, so the drift shows up here rather than as a bundle that one of
|
|
them waves through.
|
|
|
|
The PowerShell half is skipped where pwsh is absent; the Python half always runs.
|
|
"""
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
|
|
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
|
|
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
|
|
PWSH = shutil.which('pwsh') or shutil.which('powershell')
|
|
|
|
pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')
|
|
|
|
|
|
def build_bundle(root):
|
|
"""A minimal bundle holding every REQUIRED payload directory."""
|
|
for name, files in (
|
|
('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
|
|
'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
|
|
('python', {'python-3.14.6-amd64.exe': b'py'}),
|
|
('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
|
|
):
|
|
directory = root / name
|
|
directory.mkdir(parents=True)
|
|
for filename, content in files.items():
|
|
(directory / filename).write_bytes(content)
|
|
return root
|
|
|
|
|
|
def write_lock(bundle, lock_path):
|
|
"""Generate the lock the same way refresh-bundle-lock.ps1 does."""
|
|
sys.path.insert(0, str(INSTALLER))
|
|
import verify_bundle_lock as verifier
|
|
|
|
payloads = {}
|
|
for name, required, _what in verifier.PAYLOADS:
|
|
directory = bundle / name
|
|
if not directory.is_dir():
|
|
continue
|
|
payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
|
|
lock_path.write_text(json.dumps({
|
|
'schema': 1, 'generated': '2026-08-03T00:00:00Z',
|
|
'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
|
|
}))
|
|
|
|
|
|
def check_python(bundle, lock_path):
|
|
result = subprocess.run(
|
|
[sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
|
|
capture_output=True, text=True)
|
|
problems = [line for line in result.stdout.splitlines() if line.strip()]
|
|
# Exit status and output must agree, or a caller that checks only one of them
|
|
# gets a different answer from a caller that checks the other.
|
|
assert (result.returncode != 0) == bool(problems), result.stdout
|
|
return problems
|
|
|
|
|
|
def check_powershell(bundle, lock_path):
|
|
script = (
|
|
". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
|
|
"if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
|
|
result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
|
|
capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def check_both(bundle, lock_path):
|
|
"""Returns the Python verdict, asserting PowerShell reports the same set.
|
|
|
|
Compared as sets: both sort their output, but PowerShell's Sort-Object is
|
|
culture-aware and Python's sorted() is ordinal, so the two can order the same
|
|
findings differently. What must never differ is WHICH problems are found.
|
|
"""
|
|
problems = check_python(bundle, lock_path)
|
|
if PWSH and LOCK_PS1.exists():
|
|
assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
|
|
'verify_bundle_lock.py and bundle-lock.ps1 disagree')
|
|
return problems
|
|
|
|
|
|
@pytest.fixture
|
|
def locked(tmp_path):
|
|
bundle = build_bundle(tmp_path / 'bundle')
|
|
lock = tmp_path / 'bundle-lock.json'
|
|
write_lock(bundle, lock)
|
|
return bundle, lock
|
|
|
|
|
|
def test_untouched_bundle_passes(locked):
|
|
bundle, lock = locked
|
|
assert check_both(bundle, lock) == []
|
|
|
|
|
|
def test_extra_file_fails(locked):
|
|
"""The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
|
|
bundle, lock = locked
|
|
(bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
|
|
problems = check_both(bundle, lock)
|
|
assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)
|
|
|
|
|
|
def test_altered_file_fails(locked):
|
|
bundle, lock = locked
|
|
(bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
|
|
problems = check_both(bundle, lock)
|
|
assert any('does NOT match the lock' in p for p in problems)
|
|
|
|
|
|
def test_missing_file_fails(locked):
|
|
bundle, lock = locked
|
|
(bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
|
|
problems = check_both(bundle, lock)
|
|
assert any('missing from the bundle' in p for p in problems)
|
|
|
|
|
|
def test_missing_required_payload_fails(locked):
|
|
bundle, lock = locked
|
|
shutil.rmtree(bundle / 'python')
|
|
problems = check_both(bundle, lock)
|
|
assert any(p.startswith('python/') for p in problems)
|
|
|
|
|
|
def test_unlocked_payload_directory_fails(locked):
|
|
"""An optional payload dropped in after the lock was made is still unreviewed."""
|
|
bundle, lock = locked
|
|
(bundle / 'mysql').mkdir()
|
|
(bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
|
|
problems = check_both(bundle, lock)
|
|
assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)
|
|
|
|
|
|
def test_absent_optional_payload_is_fine(locked):
|
|
bundle, lock = locked
|
|
assert not (bundle / 'mysql').exists()
|
|
assert check_both(bundle, lock) == []
|
|
|
|
|
|
def test_missing_lock_fails_closed(locked):
|
|
"""No lock is a failure, not a pass. The build must not fall through to 'fine'."""
|
|
bundle, lock = locked
|
|
lock.unlink()
|
|
result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
|
|
capture_output=True, text=True)
|
|
assert result.returncode != 0
|
|
assert 'no bundle-lock.json' in result.stdout
|
|
|
|
|
|
def test_malformed_lock_reports_rather_than_crashing(locked):
|
|
"""A truncated lock must produce a sentence, not a stack trace.
|
|
|
|
shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
|
|
that is not there throws. Both verifiers have to survive a lock missing the
|
|
section they read first.
|
|
"""
|
|
bundle, lock = locked
|
|
lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
|
|
problems = check_both(bundle, lock)
|
|
assert problems == ['bundle-lock.json has no "payloads" section']
|
|
|
|
|
|
def test_lock_missing_files_section_is_not_a_silent_pass(locked):
|
|
"""A payload entry with no file list must not read as 'nothing expected, fine'."""
|
|
bundle, lock = locked
|
|
data = json.loads(lock.read_text())
|
|
del data['payloads']['wheels']['files']
|
|
lock.write_text(json.dumps(data))
|
|
problems = check_both(bundle, lock)
|
|
assert problems, 'an entry with no file list let every wheel through'
|
|
assert all('wheels/' in p for p in problems)
|
|
|
|
|
|
def test_wheelhouse_must_cover_requirements(locked):
|
|
"""The regression that motivated the check.
|
|
|
|
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
|
|
sys_platform markers against the downloading machine. The lock alone cannot
|
|
catch it, because the lock records what is there, not what is needed.
|
|
"""
|
|
bundle, lock = locked
|
|
app = bundle / 'app'
|
|
app.mkdir()
|
|
(app / 'requirements.txt').write_text(
|
|
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
|
|
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
|
|
problems = check_both(bundle, lock)
|
|
assert any('colorama==0.4.6' in p for p in problems), problems
|
|
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
|
|
|
|
|
|
def test_wheelhouse_coverage_normalizes_names(locked):
|
|
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
|
|
bundle, lock = locked
|
|
app = bundle / 'app'
|
|
app.mkdir()
|
|
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
|
|
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
|
|
|
|
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
|
|
wheel.write_bytes(b'connector')
|
|
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
|
|
assert check_both(bundle, lock) == []
|
|
|
|
|
|
def test_payload_list_matches_powershell():
|
|
"""Both files enumerate the payload directories. Same names, same required
|
|
flags, same order - a directory that is required in one and optional in the
|
|
other means the builder and the installer disagree about what may be absent.
|
|
"""
|
|
sys.path.insert(0, str(INSTALLER))
|
|
import verify_bundle_lock as verifier
|
|
|
|
declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
|
|
LOCK_PS1.read_text())
|
|
assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
|
|
assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]
|