QTZPOC-20: Refactor model import workflow, introduce atomic model ID generation, and clarify MinIO expiry. Fuses bundle download, decryption, MLflow experiment creation, and artifact upload into a single activity to prevent cross-worker issues. Implements an atomic counter for MongoDB model IDs, replacing the max(id) + 1 scan to avoid collisions during concurrent imports. Adds comprehensive documentation for MinIO object expiry mechanisms and refines lifecycle rule application. Improves error handling with clearer responsibility classification and centralizes MLflow repository injection.
643 lines
22 KiB
Python
643 lines
22 KiB
Python
"""The seven gates: what each one rejects, in what order, and what it leaves on disk."""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import random
|
|
import stat
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import nacl.bindings as sodium
|
|
import pytest
|
|
|
|
from laborious.utils.bundle import format as fmt
|
|
from laborious.utils.bundle.reader import BundleGateError, BundleLimits, BundleReader
|
|
from laborious.utils.bundle.steps import ImportStep
|
|
from tests.helpers import bundle_factory as factory
|
|
|
|
OBJECT_KEY = 'imported_models/factory_model_v2.sientia'
|
|
|
|
|
|
@pytest.fixture
|
|
def reader() -> BundleReader:
|
|
return BundleReader()
|
|
|
|
|
|
def _bundle_file(work_dir: Path, payload: bytes) -> Path:
|
|
target = work_dir / 'download.sientia'
|
|
target.write_bytes(payload)
|
|
return target
|
|
|
|
|
|
def _open(reader: BundleReader, work_dir: Path, payload: bytes, password: str | None = None):
|
|
return reader.open(
|
|
_bundle_file(work_dir, payload),
|
|
password if password is not None else factory.DEFAULT_PASSWORD,
|
|
work_dir / 'extract',
|
|
work_dir,
|
|
)
|
|
|
|
|
|
def _extracted_files(root: Path) -> list[str]:
|
|
if not root.exists():
|
|
return []
|
|
return sorted(str(p.relative_to(root)) for p in root.rglob('*') if p.is_file())
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 1
|
|
|
|
|
|
def test_gate_1_accepts_a_well_shaped_object(reader):
|
|
reader.check_object_limits(size=1024, key=OBJECT_KEY)
|
|
|
|
|
|
def test_gate_1_rejects_an_oversized_object(reader):
|
|
limited = BundleReader(BundleLimits(max_object_bytes=1000))
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
limited.check_object_limits(size=1001, key=OBJECT_KEY)
|
|
|
|
assert error.value.gate == 1
|
|
assert error.value.step is ImportStep.DOWNLOAD
|
|
|
|
|
|
def test_gate_1_rejects_an_empty_object(reader):
|
|
with pytest.raises(BundleGateError, match='empty'):
|
|
reader.check_object_limits(size=0, key=OBJECT_KEY)
|
|
|
|
|
|
def test_gate_1_rejects_a_key_outside_the_import_prefix(reader):
|
|
with pytest.raises(BundleGateError, match='import prefix'):
|
|
reader.check_object_limits(size=10, key='other_place/model.sientia')
|
|
|
|
|
|
def test_gate_1_rejects_a_key_with_the_wrong_suffix(reader):
|
|
with pytest.raises(BundleGateError, match='.sientia'):
|
|
reader.check_object_limits(size=10, key='imported_models/model.zip')
|
|
|
|
|
|
# ------------------------------------------------------------- golden bundle
|
|
|
|
|
|
def test_opens_the_producer_fixture(golden_bundle, golden_password, work_dir, reader):
|
|
"""The producer's own bytes, at the producer's own KDF cost."""
|
|
opened = reader.open(golden_bundle, golden_password, work_dir / 'extract', work_dir)
|
|
|
|
assert opened.metadata['model_name'] == 'fixture_model'
|
|
assert opened.metadata['experiment_name'] == 'fixture_experiment'
|
|
assert set(opened.metadata) == {
|
|
'model_name',
|
|
'experiment_name',
|
|
'run_id',
|
|
'model_version',
|
|
'model_project',
|
|
'export_timestamp',
|
|
}
|
|
assert opened.parameters['target_variable'] == 'SE'
|
|
assert opened.header is not None
|
|
assert opened.digest == opened.header.ciphertext_digest.hex()
|
|
assert _extracted_files(opened.extracted_dir) == [
|
|
'artifacts/data_model/MLmodel',
|
|
'artifacts/data_model/conda.yaml',
|
|
'artifacts/data_model/model.pkl',
|
|
'artifacts/data_model/python_env.yaml',
|
|
'artifacts/data_model/requirements.txt',
|
|
'artifacts/model_card/model_card.json',
|
|
'artifacts/prediction_model/MLmodel',
|
|
'artifacts/prediction_model/conda.yaml',
|
|
'artifacts/prediction_model/model.pkl',
|
|
'artifacts/prediction_model/python_env.yaml',
|
|
'artifacts/prediction_model/requirements.txt',
|
|
'artifacts/raw_data.csv',
|
|
'artifacts/test_data.csv',
|
|
'artifacts/train_data.csv',
|
|
'metadata.json',
|
|
]
|
|
assert list(work_dir.glob('*.zip')) == []
|
|
|
|
|
|
def test_fixture_metadata_matches_its_committed_plaintext_copy(
|
|
golden_bundle, golden_password, work_dir, reader
|
|
):
|
|
from tests.laborious.utils.bundle.conftest import GOLDEN_METADATA
|
|
|
|
opened = reader.open(golden_bundle, golden_password, work_dir / 'extract', work_dir)
|
|
committed = json.loads(GOLDEN_METADATA.read_text(encoding='utf-8'))
|
|
|
|
assert opened.metadata == committed['metadata']
|
|
assert opened.parameters == committed['parameters']
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 2
|
|
|
|
|
|
def test_gate_2_rejects_a_wrong_magic_before_deriving(work_dir, reader, monkeypatch):
|
|
payload = factory.make_bundle(magic=b'NOTSIENT')
|
|
monkeypatch.setattr(
|
|
sodium, 'crypto_pwhash_alg', _never_called('key derivation must not run at gate 2')
|
|
)
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 2
|
|
assert error.value.step is ImportStep.DOWNLOAD
|
|
|
|
|
|
def test_gate_2_rejects_kdf_parameters_above_the_ceiling_without_allocating(
|
|
work_dir, reader, monkeypatch
|
|
):
|
|
payload = factory.make_bundle(declared_memlimit=512 * 1024 * 1024)
|
|
monkeypatch.setattr(
|
|
sodium, 'crypto_pwhash_alg', _never_called('the ceiling must be enforced before deriving')
|
|
)
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 2
|
|
|
|
|
|
def test_gate_2_rejects_a_digest_mismatch(work_dir, reader):
|
|
payload = factory.make_bundle(digest_override=bytes(32))
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 2
|
|
|
|
|
|
def test_gate_2_rejects_a_truncated_transfer(work_dir, reader):
|
|
payload = factory.truncate_bundle(factory.make_bundle())
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 2
|
|
|
|
|
|
def test_gate_2_wraps_an_os_error_reading_the_file(work_dir, reader):
|
|
"""A download that never landed on disk is an OS error, not a format one."""
|
|
missing = work_dir / 'never-downloaded.sientia'
|
|
|
|
with pytest.raises(BundleGateError, match='could not be read') as error:
|
|
reader.open(missing, factory.DEFAULT_PASSWORD, work_dir / 'extract', work_dir)
|
|
|
|
assert error.value.gate == 2
|
|
|
|
|
|
def _never_called(message: str):
|
|
def _raise(*_args, **_kwargs):
|
|
raise AssertionError(message)
|
|
|
|
return _raise
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 3
|
|
|
|
AMBIGUOUS_REASON = 'wrong password, or the file is damaged or was altered'
|
|
|
|
|
|
def test_gate_3_wrong_password(work_dir, reader):
|
|
payload = factory.make_bundle()
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload, password='Wrong1Password!Here')
|
|
|
|
assert error.value.gate == 3
|
|
assert error.value.step is ImportStep.DECRYPTION
|
|
assert AMBIGUOUS_REASON in str(error.value)
|
|
|
|
|
|
def test_gate_3_altered_ciphertext_reads_the_same_as_a_wrong_password(work_dir, reader):
|
|
tampered = factory.tamper_ciphertext(factory.make_bundle())
|
|
|
|
with pytest.raises(BundleGateError) as altered:
|
|
_open(reader, work_dir, tampered)
|
|
|
|
wrong_password_payload = factory.make_bundle()
|
|
with pytest.raises(BundleGateError) as wrong_password:
|
|
_open(reader, work_dir, wrong_password_payload, password='Wrong1Password!Here')
|
|
|
|
assert str(altered.value) == str(wrong_password.value)
|
|
assert altered.value.gate == wrong_password.value.gate == 3
|
|
|
|
|
|
def test_gate_3_stream_without_a_final_tag_is_rejected(work_dir, reader):
|
|
payload = factory.make_bundle(drop_final_tag=True)
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 3
|
|
assert AMBIGUOUS_REASON in str(error.value)
|
|
|
|
|
|
def test_gate_3_wraps_an_os_error_while_writing_the_decrypted_zip(work_dir, reader, monkeypatch):
|
|
"""A write failure while streaming the plaintext to disk is reported as disk trouble, not as
|
|
a bad password — the AEAD never even got to disagree."""
|
|
payload = factory.make_bundle()
|
|
|
|
def _raise_os_error(*_args, **_kwargs):
|
|
raise OSError('disk full')
|
|
|
|
monkeypatch.setattr(reader, '_pull_stream', _raise_os_error)
|
|
|
|
with pytest.raises(BundleGateError, match='could not be written') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 3
|
|
|
|
|
|
def test_gate_3_leaves_no_decrypted_zip_behind(work_dir, reader):
|
|
payload = factory.make_bundle()
|
|
|
|
with pytest.raises(BundleGateError):
|
|
_open(reader, work_dir, payload, password='Wrong1Password!Here')
|
|
|
|
assert list(work_dir.glob('*.zip')) == []
|
|
|
|
|
|
def test_gate_3_normalises_the_password_to_nfc(work_dir, reader):
|
|
composed = 'Café1Model!Test'
|
|
decomposed = 'Café1Model!Test'
|
|
payload = factory.make_bundle(password=composed)
|
|
|
|
opened = _open(reader, work_dir, payload, password=decomposed)
|
|
|
|
assert opened.metadata['model_name'] == 'factory_model'
|
|
|
|
|
|
def test_gate_3_does_not_trim_the_password(work_dir, reader):
|
|
payload = factory.make_bundle(password='Trailing1Space!Test ')
|
|
|
|
with pytest.raises(BundleGateError):
|
|
_open(reader, work_dir, payload, password='Trailing1Space!Test')
|
|
|
|
|
|
def test_multi_chunk_plaintext_round_trips(work_dir, reader):
|
|
"""More than 64 KiB of plaintext, so `MESSAGE` and `FINAL` framing are both exercised."""
|
|
spec = factory.ZipSpec()
|
|
# Incompressible on purpose: a compressible filler would deflate below one chunk and the test
|
|
# would silently stop covering multi-chunk framing.
|
|
spec.files['artifacts/prediction_model/model.pkl'] = random.Random( # noqa: S311 - filler
|
|
20260825
|
|
).randbytes(200 * 1024)
|
|
payload = factory.make_bundle(spec)
|
|
assert len(payload) > fmt.CHUNK_SIZE + fmt.ABYTES + fmt.DIGEST_OFFSET
|
|
|
|
opened = _open(reader, work_dir, payload)
|
|
|
|
extracted = opened.extracted_dir / 'artifacts' / 'prediction_model' / 'model.pkl'
|
|
assert extracted.read_bytes() == spec.files['artifacts/prediction_model/model.pkl']
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 4
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('spec_kwargs', 'match'),
|
|
[
|
|
({'bomb_bytes': 40 * 1024 * 1024}, 'compression ratio'),
|
|
({'traversal_entry': 'artifacts/../../etc/passwd'}, 'escaping'),
|
|
({'absolute_entry': '/etc/passwd'}, 'absolute'),
|
|
({'symlinks': {'artifacts/link': '/etc/passwd'}}, 'symbolic link'),
|
|
],
|
|
)
|
|
def test_gate_4_rejects_hostile_central_directories(work_dir, reader, spec_kwargs, match):
|
|
payload = factory.make_bundle(factory.ZipSpec(**spec_kwargs))
|
|
|
|
with pytest.raises(BundleGateError, match=match) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
assert error.value.step is ImportStep.ARCHIVE_INSPECTION
|
|
assert _extracted_files(work_dir / 'extract') == []
|
|
assert not (work_dir / 'extract').exists()
|
|
|
|
|
|
def test_gate_4_rejects_too_many_entries(work_dir):
|
|
limited = BundleReader(BundleLimits(max_entries=3))
|
|
payload = factory.make_bundle()
|
|
|
|
with pytest.raises(BundleGateError, match='entries') as error:
|
|
_open(limited, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
|
|
|
|
def test_gate_4_rejects_an_oversized_uncompressed_total(work_dir):
|
|
limited = BundleReader(BundleLimits(max_uncompressed_bytes=64))
|
|
payload = factory.make_bundle()
|
|
|
|
with pytest.raises(BundleGateError, match='uncompressed') as error:
|
|
_open(limited, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
|
|
|
|
def test_gate_4_rejects_content_that_is_not_an_archive(work_dir, reader):
|
|
payload = factory.encrypt_archive(b'not a zip at all')
|
|
|
|
with pytest.raises(BundleGateError, match='readable archive') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
|
|
|
|
def test_gate_4_rejects_an_empty_archive(work_dir, reader):
|
|
payload = factory.make_bundle(factory.ZipSpec(files={}, include_metadata=False))
|
|
|
|
with pytest.raises(BundleGateError, match='archive is empty') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
|
|
|
|
def test_gate_4_rejects_an_entry_that_is_neither_a_file_nor_a_directory(work_dir, reader):
|
|
"""A FIFO's mode bits are neither a symlink's, a regular file's, nor a directory's."""
|
|
payload = factory.make_bundle(
|
|
factory.ZipSpec(special_mode_entries={'artifacts/pipe': stat.S_IFIFO | 0o644})
|
|
)
|
|
|
|
with pytest.raises(BundleGateError, match='not a regular file') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
|
|
|
|
def test_gate_4_and_gate_5_read_the_central_directory_once(
|
|
golden_bundle, golden_password, work_dir, reader, monkeypatch
|
|
):
|
|
"""Achado #11: gate 5 extracts using the entries gate 4 already read, instead of reopening the
|
|
archive to call `infolist()` a second time."""
|
|
original_infolist = zipfile.ZipFile.infolist
|
|
calls: list[None] = []
|
|
|
|
def _counting_infolist(self):
|
|
calls.append(None)
|
|
return original_infolist(self)
|
|
|
|
monkeypatch.setattr(zipfile.ZipFile, 'infolist', _counting_infolist)
|
|
|
|
reader.open(golden_bundle, golden_password, work_dir / 'extract', work_dir)
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 5
|
|
|
|
|
|
def test_gate_5_refuses_a_pre_existing_extraction_root(work_dir, reader):
|
|
(work_dir / 'extract').mkdir()
|
|
payload = factory.make_bundle()
|
|
|
|
with pytest.raises(BundleGateError, match='already exists') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 5
|
|
assert error.value.step is ImportStep.EXTRACTION
|
|
|
|
|
|
def test_gate_5_extraction_writes_plain_files(work_dir, reader):
|
|
opened = _open(reader, work_dir, factory.make_bundle())
|
|
|
|
for path in opened.extracted_dir.rglob('*'):
|
|
if path.is_file():
|
|
assert path.stat().st_mode & 0o111 == 0, path
|
|
|
|
|
|
def test_gate_5_extraction_creates_declared_directory_entries(work_dir, reader):
|
|
"""The producer never writes directory entries, but a bundle from anywhere else might."""
|
|
payload = factory.make_bundle(factory.ZipSpec(directory_entries=['artifacts/empty_dir']))
|
|
|
|
opened = _open(reader, work_dir, payload)
|
|
|
|
assert (opened.extracted_dir / 'artifacts' / 'empty_dir').is_dir()
|
|
|
|
|
|
def test_gate_5_rejects_a_duplicate_entry_name(work_dir, reader):
|
|
"""Two archive entries claiming the same path: gate 4 checks each name on its own and cannot
|
|
see the collision, so extraction is where an overwrite is caught — and rejected."""
|
|
payload = factory.make_bundle(factory.ZipSpec(repeat_file='artifacts/prediction_model/MLmodel'))
|
|
|
|
with pytest.raises(BundleGateError, match='overwrite') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 5
|
|
assert not (work_dir / 'extract').exists()
|
|
|
|
|
|
def test_gate_5_wraps_an_os_error_during_unpacking(work_dir, reader, monkeypatch):
|
|
"""A write failure partway through extraction (e.g. disk full) is reported as unpacking
|
|
trouble, not surfaced as the raw OSError."""
|
|
payload = factory.make_bundle()
|
|
|
|
def _raise_os_error(*_args, **_kwargs):
|
|
raise OSError('disk full')
|
|
|
|
monkeypatch.setattr('laborious.utils.bundle.reader.shutil.copyfileobj', _raise_os_error)
|
|
|
|
with pytest.raises(BundleGateError, match='could not be unpacked') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 5
|
|
assert not (work_dir / 'extract').exists()
|
|
|
|
|
|
def test_extract_member_rechecks_containment_on_the_resolved_path(work_dir, reader):
|
|
"""Gate 4 already rejects a traversal by name; extraction re-checks on the *resolved* path as
|
|
a second, independent line of defense."""
|
|
root = work_dir / 'root'
|
|
root.mkdir()
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, 'w') as archive:
|
|
archive.writestr('safe.txt', b'hello')
|
|
|
|
with zipfile.ZipFile(io.BytesIO(buffer.getvalue())) as archive:
|
|
entry = archive.infolist()[0]
|
|
with pytest.raises(BundleGateError, match='resolves outside') as error:
|
|
reader._extract_member(archive, entry, root, work_dir / 'somewhere-else')
|
|
|
|
assert error.value.gate == 5
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 6
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('spec', 'match'),
|
|
[
|
|
(factory.ZipSpec(include_metadata=False), 'metadata document'),
|
|
(
|
|
factory.ZipSpec(
|
|
document=factory.build_metadata_document(drop_top_level_key='parameters')
|
|
),
|
|
'parameters and metadata blocks',
|
|
),
|
|
(
|
|
factory.ZipSpec(
|
|
document=factory.build_metadata_document(drop_metadata_field='experiment_name')
|
|
),
|
|
'experiment_name',
|
|
),
|
|
(
|
|
factory.ZipSpec(
|
|
document=factory.build_metadata_document(drop_parameter='target_variable')
|
|
),
|
|
'target_variable',
|
|
),
|
|
(
|
|
factory.ZipSpec(
|
|
document=factory.build_metadata_document(metadata={'experiment_name': 'x' * 51})
|
|
),
|
|
'experiment name',
|
|
),
|
|
(
|
|
factory.ZipSpec(
|
|
document=factory.build_metadata_document(metadata={'experiment_name': 'ab'})
|
|
),
|
|
'experiment name',
|
|
),
|
|
(
|
|
factory.ZipSpec(document={'metadata': 'not-a-dict', 'parameters': {}}),
|
|
'not objects',
|
|
),
|
|
],
|
|
)
|
|
def test_gate_6_rejects_a_malformed_metadata_document(work_dir, reader, spec, match):
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError, match=match) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 6
|
|
assert error.value.step is ImportStep.STRUCTURE_VALIDATION
|
|
assert not (work_dir / 'extract').exists()
|
|
|
|
|
|
def test_gate_6_rejects_unreadable_metadata_json(work_dir, reader):
|
|
spec = factory.ZipSpec(include_metadata=False)
|
|
spec.extra_files['metadata.json'] = b'{not json'
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError, match='readable JSON') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 6
|
|
|
|
|
|
def test_gate_6_rejects_a_missing_artifacts_directory(work_dir, reader):
|
|
spec = factory.ZipSpec(files={})
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError, match='artifacts directory') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 6
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('dropped', 'match'),
|
|
[
|
|
('artifacts/data_model/MLmodel', 'data_model artifacts are incomplete'),
|
|
('artifacts/prediction_model/model.pkl', 'prediction_model artifacts are incomplete'),
|
|
],
|
|
)
|
|
def test_gate_6_rejects_an_incomplete_model_directory(work_dir, reader, dropped, match):
|
|
spec = factory.ZipSpec()
|
|
spec.files.pop(dropped)
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError, match=match) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 6
|
|
|
|
|
|
def test_gate_6_rejects_a_bundle_without_data_model(work_dir, reader):
|
|
spec = factory.ZipSpec()
|
|
for name in list(spec.files):
|
|
if name.startswith('artifacts/data_model/'):
|
|
spec.files.pop(name)
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError, match='no data_model artifacts') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 6
|
|
|
|
|
|
# ------------------------------------------------------------------- gate 7
|
|
|
|
|
|
@pytest.mark.parametrize('name', ['artifacts/run.sh', 'artifacts/tool.bin', 'artifacts/mod.so'])
|
|
def test_gate_7_rejects_content_a_model_export_should_not_carry(work_dir, reader, name):
|
|
spec = factory.ZipSpec()
|
|
spec.extra_files[name] = b'#!/bin/sh\n'
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError, match='should not carry') as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 7
|
|
assert error.value.step is ImportStep.CONTENT_POLICY
|
|
assert not (work_dir / 'extract').exists()
|
|
|
|
|
|
def test_gate_7_rejects_a_non_regular_file_left_in_the_tree(work_dir, reader):
|
|
"""Extraction itself only ever writes plain files and directories; this is the check that
|
|
would catch anything else showing up in the tree by some other means."""
|
|
root = work_dir / 'weird_root'
|
|
root.mkdir()
|
|
os.mkfifo(root / 'pipe')
|
|
|
|
with pytest.raises(BundleGateError, match='not a file') as error:
|
|
reader._gate_7_content_policy(root)
|
|
|
|
assert error.value.gate == 7
|
|
|
|
|
|
def test_gate_7_rejects_disallowed_content_regardless_of_traversal_order(work_dir, reader):
|
|
"""Achado #12: gate 7 no longer sorts the tree before walking it, so a disallowed file that
|
|
would not be first or last alphabetically must still be caught."""
|
|
root = work_dir / 'unsorted_root'
|
|
root.mkdir()
|
|
for name in ('a_file.txt', 'b_file.txt', 'y_file.txt', 'z_file.txt'):
|
|
(root / name).write_bytes(b'ok')
|
|
(root / 'n_disallowed.sh').write_bytes(b'#!/bin/sh\n')
|
|
|
|
with pytest.raises(BundleGateError, match='should not carry') as error:
|
|
reader._gate_7_content_policy(root)
|
|
|
|
assert error.value.gate == 7
|
|
|
|
|
|
# ------------------------------------------------------------------ ordering
|
|
|
|
|
|
def test_the_earlier_gate_wins(work_dir, reader):
|
|
"""A bundle that would fail gate 4 and gate 6 must report gate 4, having extracted nothing."""
|
|
spec = factory.ZipSpec(
|
|
traversal_entry='artifacts/../escape.csv',
|
|
document=factory.build_metadata_document(drop_parameter='target_variable'),
|
|
)
|
|
payload = factory.make_bundle(spec)
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload)
|
|
|
|
assert error.value.gate == 4
|
|
assert _extracted_files(work_dir / 'extract') == []
|
|
|
|
|
|
def test_header_is_checked_before_the_password(work_dir, reader):
|
|
"""A bundle with a bad header and a wrong password reports the header, not the password."""
|
|
payload = factory.make_bundle(magic=b'NOTSIENT')
|
|
|
|
with pytest.raises(BundleGateError) as error:
|
|
_open(reader, work_dir, payload, password='Nope1!Nope1!')
|
|
|
|
assert error.value.gate == 2
|