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]
|