Files
shopdb-flask/tests/test_bundle_lock.py
cproudlock 88af7fd9ce feat(installer): lock the third-party payload, and build on Windows without Bash
The bundle carries ~40 wheels, a Python installer and two MSIs. All of them run
as SYSTEM on the target server, and nothing verified any of them. A missing
wheelhouse printed MISSING and the script still exited 0, so an empty bundle
compiled into a shippable installer and the failure surfaced on an air-gapped
server with no way to fix it.

bundle-lock.json now records that payload exactly - sha256 and byte size per
file - and verification is set equality: a missing file, an unexpected extra
file, or changed content all fail. Both builders check it and refuse to produce
an unverified bundle; the lock ships inside the bundle and shopdb-install.ps1
re-checks it on the server before running any of it.

This is deliberately a layer above requirements.txt hashes. pip lists every
artifact of a pinned version (cffi 2.1.0 alone has 100 hashes), so it proves a
wheel is genuine, not that it is the wheel this bundle was built and tested
with; it ignores extra files in the wheelhouse; and it covers none of the
executables.

refresh-bundle-lock.ps1 regenerates the lock but refuses to overwrite one until
the operator has seen the diff, because the commit is the review - it is the
only place a change to what runs as SYSTEM becomes visible to a human.

build-installer.ps1 is the whole build natively on Windows, so a work PC needs
no Bash. It shares the plugin closure resolver with build-site.sh.

Both builders now copy the installer scripts from the repository. They were
copied from a downloads folder, so the logic that shipped was not the logic that
was committed and the build worked on exactly one machine.

Two verifiers exist because PowerShell is the only thing guaranteed present on
the target server, while the Linux builder should not need pwsh.
tests/test_bundle_lock.py runs both against the same fixtures and fails if they
disagree.
2026-08-03 11:17:45 -04:00

200 lines
7.7 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_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]