vector-mtrl 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mtrl/__init__.py +144 -0
- mtrl/_alignment_report.py +105 -0
- mtrl/_archive_documents.py +73 -0
- mtrl/_archive_integrity.py +539 -0
- mtrl/_archive_loading.py +1070 -0
- mtrl/_archive_serialise.py +696 -0
- mtrl/_candidate_matching.py +216 -0
- mtrl/_cli.py +28 -0
- mtrl/_disclosure.py +735 -0
- mtrl/_ingestion_shortcuts.py +99 -0
- mtrl/_install_targets.py +36 -0
- mtrl/_optional.py +38 -0
- mtrl/_provenance.py +32 -0
- mtrl/_receipts.py +211 -0
- mtrl/_record_resolution.py +60 -0
- mtrl/_replay_plan.py +557 -0
- mtrl/_replay_run.py +734 -0
- mtrl/_run_management.py +400 -0
- mtrl/_source_capture.py +507 -0
- mtrl/_table_plan.py +271 -0
- mtrl/_table_provenance.py +387 -0
- mtrl/_table_values.py +188 -0
- mtrl/_transform_apply.py +223 -0
- mtrl/_transform_validation.py +1367 -0
- mtrl/archive/__init__.py +54 -0
- mtrl/archive/canonical.py +45 -0
- mtrl/archive/codecs.py +88 -0
- mtrl/archive/dtypes.py +39 -0
- mtrl/archive/reader.py +945 -0
- mtrl/archive/validation.py +1872 -0
- mtrl/connectors/__init__.py +1 -0
- mtrl/connectors/protocols.py +84 -0
- mtrl/disclosure.py +273 -0
- mtrl/domain/__init__.py +129 -0
- mtrl/domain/archive_limits.py +46 -0
- mtrl/domain/committed_values.py +539 -0
- mtrl/domain/frozen_containers.py +168 -0
- mtrl/domain/identities.py +174 -0
- mtrl/domain/provenance.py +448 -0
- mtrl/domain/records.py +233 -0
- mtrl/domain/revisions.py +121 -0
- mtrl/domain/source.py +13 -0
- mtrl/domain/verification.py +302 -0
- mtrl/excel.py +139 -0
- mtrl/exceptions.py +215 -0
- mtrl/exports.py +326 -0
- mtrl/features.py +251 -0
- mtrl/frames.py +228 -0
- mtrl/grids.py +1172 -0
- mtrl/integrity/__init__.py +143 -0
- mtrl/integrity/canonical.py +511 -0
- mtrl/integrity/digests.py +363 -0
- mtrl/integrity/manifest.py +631 -0
- mtrl/integrity/receipts.py +771 -0
- mtrl/integrity/verification.py +1303 -0
- mtrl/models.py +952 -0
- mtrl/mtrl.py +1852 -0
- mtrl/operations/__init__.py +36 -0
- mtrl/operations/_identity.py +17 -0
- mtrl/operations/_validation.py +106 -0
- mtrl/operations/arithmetic.py +207 -0
- mtrl/operations/base.py +292 -0
- mtrl/operations/resampling.py +203 -0
- mtrl/operations/resampling_scipy.py +138 -0
- mtrl/operations/spectra.py +58 -0
- mtrl/processing/__init__.py +23 -0
- mtrl/processing/_table_capture.py +341 -0
- mtrl/processing/limits.py +9 -0
- mtrl/processing/registry.py +1533 -0
- mtrl/protocols.py +13 -0
- mtrl/py.typed +0 -0
- mtrl/replay.py +946 -0
- mtrl/tables.py +1365 -0
- mtrl/transformations/__init__.py +22 -0
- mtrl/transformations/base.py +17 -0
- mtrl/units.py +60 -0
- vector_mtrl-0.2.0.dist-info/METADATA +142 -0
- vector_mtrl-0.2.0.dist-info/RECORD +81 -0
- vector_mtrl-0.2.0.dist-info/WHEEL +4 -0
- vector_mtrl-0.2.0.dist-info/licenses/LICENSE +373 -0
- vector_mtrl-0.2.0.dist-info/licenses/NOTICE +30 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Integrity validation and digest checks for new-format archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
|
+
|
|
10
|
+
from mtrl._archive_documents import (
|
|
11
|
+
_CANONICAL_OPERATION_PARAMETER_PATHS,
|
|
12
|
+
_deserialise_metadata_field,
|
|
13
|
+
_normalise_new_object_list,
|
|
14
|
+
)
|
|
15
|
+
from mtrl.archive import BoundedZipReader, decode_numpy
|
|
16
|
+
from mtrl.archive.validation import ArchiveDocuments, validate_decoded_numpy_payload
|
|
17
|
+
from mtrl.domain import CoverageStatus, Revision
|
|
18
|
+
from mtrl.exceptions import (
|
|
19
|
+
CanonicalisationError,
|
|
20
|
+
InsufficientCoverageError,
|
|
21
|
+
IntegrityLayer,
|
|
22
|
+
IntegrityMismatchError,
|
|
23
|
+
InvalidArchiveStructureError,
|
|
24
|
+
)
|
|
25
|
+
from mtrl.integrity.canonical import decode_schema_int
|
|
26
|
+
from mtrl.integrity.digests import (
|
|
27
|
+
DigestOrder,
|
|
28
|
+
canonical_payload_digest,
|
|
29
|
+
raw_digest,
|
|
30
|
+
record_digest,
|
|
31
|
+
revision_digest,
|
|
32
|
+
source_digest,
|
|
33
|
+
)
|
|
34
|
+
from mtrl.integrity.digests import (
|
|
35
|
+
storage_digest as calculate_storage_digest,
|
|
36
|
+
)
|
|
37
|
+
from mtrl.integrity.manifest import record_body
|
|
38
|
+
from mtrl.models import ArchiveEntry, CheckpointEncoding, CheckpointManifestEntry, Manifest
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from mtrl.mtrl import Mtrl
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _assert_integrity_mapping(
|
|
45
|
+
expected: Any,
|
|
46
|
+
candidate: Any,
|
|
47
|
+
*,
|
|
48
|
+
label: IntegrityLayer,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Compare one committed integrity mapping with a rebuilt mapping."""
|
|
51
|
+
if not isinstance(expected, Mapping) or not isinstance(candidate, Mapping):
|
|
52
|
+
return
|
|
53
|
+
for item_id, expected_value in expected.items():
|
|
54
|
+
if candidate.get(item_id) != expected_value:
|
|
55
|
+
raise IntegrityMismatchError(
|
|
56
|
+
f"Committed {label} '{item_id}' has an integrity mismatch.",
|
|
57
|
+
layer=label,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _assert_current_application_state(
|
|
62
|
+
container: Mtrl,
|
|
63
|
+
expected_integrity: Mapping[str, Any],
|
|
64
|
+
candidate_entries: Sequence[tuple[str, bytes]],
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Compare every baseline revision identity before allowing a new revision."""
|
|
67
|
+
expected_bodies = expected_integrity.get("revision_bodies", {})
|
|
68
|
+
expected_roots = expected_integrity.get("revision_roots", {})
|
|
69
|
+
if not isinstance(expected_bodies, Mapping) or not isinstance(
|
|
70
|
+
expected_roots,
|
|
71
|
+
Mapping,
|
|
72
|
+
):
|
|
73
|
+
return
|
|
74
|
+
current_revisions = {str(revision.revision_id): revision for revision in container._revisions}
|
|
75
|
+
for revision_id, expected_body in expected_bodies.items():
|
|
76
|
+
revision = current_revisions.get(str(revision_id))
|
|
77
|
+
if revision is None or not isinstance(expected_body, Mapping):
|
|
78
|
+
raise IntegrityMismatchError(
|
|
79
|
+
f"Committed revision '{revision_id}' has an integrity mismatch.",
|
|
80
|
+
layer="revision",
|
|
81
|
+
)
|
|
82
|
+
_validate_revision_body_identity(revision, expected_body)
|
|
83
|
+
if revision.revision_root != expected_roots.get(revision_id):
|
|
84
|
+
raise IntegrityMismatchError(
|
|
85
|
+
f"Committed revision '{revision_id}' has an integrity mismatch.",
|
|
86
|
+
layer="revision",
|
|
87
|
+
)
|
|
88
|
+
if len(current_revisions) == len(expected_bodies):
|
|
89
|
+
_assert_saved_entries_unchanged(container._archive_path, candidate_entries)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _assert_saved_entries_unchanged(
|
|
93
|
+
archive_path: Path | None,
|
|
94
|
+
candidate_entries: Sequence[tuple[str, bytes]],
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Reject in-place edits when no new application revision exists."""
|
|
97
|
+
if archive_path is None:
|
|
98
|
+
return
|
|
99
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
100
|
+
saved_entries = {entry.filename: archive.read(entry) for entry in archive.infolist()}
|
|
101
|
+
if dict(candidate_entries) != saved_entries:
|
|
102
|
+
raise IntegrityMismatchError(
|
|
103
|
+
"Committed evidence changed without creating a new revision.",
|
|
104
|
+
layer="storage",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _assert_integrity_inventory(expected: Any, candidate: Any) -> None:
|
|
109
|
+
"""Compare committed payload and source inventory descriptors."""
|
|
110
|
+
if not isinstance(expected, Mapping) or not isinstance(candidate, Mapping):
|
|
111
|
+
return
|
|
112
|
+
covered_kinds = {"payload", "source", "environment-record", "source-bundle"}
|
|
113
|
+
for entry_path, expected_descriptor in expected.items():
|
|
114
|
+
if not isinstance(expected_descriptor, Mapping):
|
|
115
|
+
continue
|
|
116
|
+
candidate_descriptor = candidate.get(entry_path)
|
|
117
|
+
if not isinstance(candidate_descriptor, Mapping):
|
|
118
|
+
raise IntegrityMismatchError(
|
|
119
|
+
f"Committed inventory entry '{entry_path}' is missing.",
|
|
120
|
+
layer="storage",
|
|
121
|
+
)
|
|
122
|
+
if expected_descriptor.get("kind") not in covered_kinds:
|
|
123
|
+
continue
|
|
124
|
+
if dict(candidate_descriptor) != dict(expected_descriptor):
|
|
125
|
+
raise IntegrityMismatchError(
|
|
126
|
+
f"Committed inventory entry '{entry_path}' has an integrity mismatch.",
|
|
127
|
+
layer="storage",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_integrity_manifest(
|
|
132
|
+
archive: BoundedZipReader,
|
|
133
|
+
manifest: Manifest,
|
|
134
|
+
documents: ArchiveDocuments,
|
|
135
|
+
*,
|
|
136
|
+
allow_partial: bool = False,
|
|
137
|
+
) -> CoverageStatus:
|
|
138
|
+
"""Validate new-format inventory digests and every revision root."""
|
|
139
|
+
integrity_data = documents.integrity_data
|
|
140
|
+
if not isinstance(integrity_data, Mapping):
|
|
141
|
+
raise InvalidArchiveStructureError("Archive integrity record is incomplete.")
|
|
142
|
+
inventory = integrity_data.get("inventory")
|
|
143
|
+
if not isinstance(inventory, Mapping):
|
|
144
|
+
raise InvalidArchiveStructureError("Archive integrity inventory is incomplete.")
|
|
145
|
+
_validate_integrity_entry_storage(archive, inventory)
|
|
146
|
+
archive.preflight_numpy_headers(
|
|
147
|
+
checkpoint.entry
|
|
148
|
+
for checkpoint in documents.checkpoints
|
|
149
|
+
if checkpoint.encoding == CheckpointEncoding.NPY
|
|
150
|
+
)
|
|
151
|
+
_validate_integrity_payload_digests(archive, documents.checkpoints)
|
|
152
|
+
_validate_integrity_source_digests(archive, manifest)
|
|
153
|
+
record_coverage = _validate_integrity_record_digests(
|
|
154
|
+
archive,
|
|
155
|
+
inventory,
|
|
156
|
+
integrity_data,
|
|
157
|
+
)
|
|
158
|
+
_validate_integrity_revision_roots(documents, integrity_data)
|
|
159
|
+
inventory_coverage = _validate_integrity_inventory_keys(
|
|
160
|
+
archive,
|
|
161
|
+
manifest,
|
|
162
|
+
documents,
|
|
163
|
+
integrity_data,
|
|
164
|
+
)
|
|
165
|
+
if record_coverage and inventory_coverage:
|
|
166
|
+
return CoverageStatus.FULL
|
|
167
|
+
if not allow_partial:
|
|
168
|
+
raise InsufficientCoverageError(
|
|
169
|
+
"Archive integrity coverage is partial; full manifest coverage is required."
|
|
170
|
+
)
|
|
171
|
+
return CoverageStatus.PARTIAL
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _validate_integrity_storage_before_restoration(
|
|
175
|
+
archive: BoundedZipReader,
|
|
176
|
+
integrity_data: Any,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Check covered entry bytes before structural restoration can continue."""
|
|
179
|
+
if not isinstance(integrity_data, Mapping):
|
|
180
|
+
raise InvalidArchiveStructureError("Archive integrity record is incomplete.")
|
|
181
|
+
inventory = integrity_data.get("inventory")
|
|
182
|
+
if not isinstance(inventory, Mapping):
|
|
183
|
+
raise InvalidArchiveStructureError("Archive integrity inventory is incomplete.")
|
|
184
|
+
_validate_integrity_entry_storage(
|
|
185
|
+
archive,
|
|
186
|
+
inventory,
|
|
187
|
+
entry_paths=(ArchiveEntry.REVISIONS.value,),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _validate_integrity_payload_digests(
|
|
192
|
+
archive: BoundedZipReader,
|
|
193
|
+
checkpoints: Sequence[CheckpointManifestEntry],
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Verify raw and canonical payload digests before interpretation checks."""
|
|
196
|
+
for checkpoint in sorted(checkpoints, key=lambda value: value.entry):
|
|
197
|
+
entry_bytes = archive.read_entry(checkpoint.entry)
|
|
198
|
+
if checkpoint.encoding == CheckpointEncoding.BYTES:
|
|
199
|
+
if checkpoint.raw_digest is None:
|
|
200
|
+
raise InvalidArchiveStructureError(
|
|
201
|
+
f"Payload '{checkpoint.entry}' has incomplete raw digest evidence."
|
|
202
|
+
)
|
|
203
|
+
observed_raw_digest = raw_digest(entry_bytes)
|
|
204
|
+
if observed_raw_digest != checkpoint.raw_digest:
|
|
205
|
+
raise IntegrityMismatchError(
|
|
206
|
+
f"Payload '{checkpoint.entry}' has a raw digest mismatch: "
|
|
207
|
+
f"expected {checkpoint.raw_digest}; observed {observed_raw_digest}.",
|
|
208
|
+
layer="raw",
|
|
209
|
+
)
|
|
210
|
+
continue
|
|
211
|
+
if checkpoint.storage_digest is None or checkpoint.payload_digest is None:
|
|
212
|
+
raise InvalidArchiveStructureError(
|
|
213
|
+
f"Payload '{checkpoint.entry}' has incomplete dual digest evidence."
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
archive.validate_numpy(checkpoint.entry)
|
|
217
|
+
decoded_array = decode_numpy(entry_bytes)
|
|
218
|
+
except (EOFError, ValueError, OSError) as error:
|
|
219
|
+
raise InvalidArchiveStructureError(
|
|
220
|
+
f"Payload '{checkpoint.entry}' is not valid for codec 'npy'."
|
|
221
|
+
) from error
|
|
222
|
+
validate_decoded_numpy_payload(
|
|
223
|
+
checkpoint,
|
|
224
|
+
decoded_array,
|
|
225
|
+
checkpoint.entry,
|
|
226
|
+
)
|
|
227
|
+
if checkpoint.payload_digest is None:
|
|
228
|
+
continue
|
|
229
|
+
observed_payload_digest = canonical_payload_digest(
|
|
230
|
+
decoded_array,
|
|
231
|
+
order=cast(DigestOrder, checkpoint.order),
|
|
232
|
+
)
|
|
233
|
+
if observed_payload_digest != checkpoint.payload_digest:
|
|
234
|
+
raise IntegrityMismatchError(
|
|
235
|
+
f"Payload '{checkpoint.entry}' has a canonical payload digest mismatch: "
|
|
236
|
+
f"expected {checkpoint.payload_digest}; observed {observed_payload_digest}.",
|
|
237
|
+
layer="canonical",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _validate_checkpoint_storage_digests(
|
|
242
|
+
archive: BoundedZipReader,
|
|
243
|
+
checkpoints: Sequence[CheckpointManifestEntry],
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Verify all recorded payload storage digests before NumPy preflight."""
|
|
246
|
+
for checkpoint in sorted(checkpoints, key=lambda value: value.entry):
|
|
247
|
+
if checkpoint.storage_digest is None:
|
|
248
|
+
continue
|
|
249
|
+
entry_bytes = archive.read_entry(checkpoint.entry)
|
|
250
|
+
observed_storage_digest = calculate_storage_digest(
|
|
251
|
+
checkpoint.entry,
|
|
252
|
+
entry_bytes,
|
|
253
|
+
)
|
|
254
|
+
if observed_storage_digest != checkpoint.storage_digest:
|
|
255
|
+
raise IntegrityMismatchError(
|
|
256
|
+
f"Payload '{checkpoint.entry}' has a storage digest mismatch: "
|
|
257
|
+
f"expected {checkpoint.storage_digest}; observed {observed_storage_digest}.",
|
|
258
|
+
layer="storage",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_integrity_source_digests(
|
|
263
|
+
archive: BoundedZipReader,
|
|
264
|
+
manifest: Manifest,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Verify every retained source digest over its captured bytes."""
|
|
267
|
+
for source in sorted(
|
|
268
|
+
manifest.sources.values(),
|
|
269
|
+
key=lambda value: str(value.entry or value.source_id or value.filename),
|
|
270
|
+
):
|
|
271
|
+
if source.entry is None or source.digest is None:
|
|
272
|
+
continue
|
|
273
|
+
observed_source_digest = source_digest(archive.read_entry(source.entry))
|
|
274
|
+
if observed_source_digest != source.digest:
|
|
275
|
+
raise IntegrityMismatchError(
|
|
276
|
+
f"Source '{source.entry}' has a digest mismatch: expected "
|
|
277
|
+
f"{source.digest}; observed {observed_source_digest}.",
|
|
278
|
+
layer="source",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _validate_integrity_entry_storage(
|
|
283
|
+
archive: BoundedZipReader,
|
|
284
|
+
inventory: Mapping[str, Any],
|
|
285
|
+
*,
|
|
286
|
+
entry_paths: Iterable[str] | None = None,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Validate storage digests for every physical archive entry."""
|
|
289
|
+
paths = archive.entry_paths if entry_paths is None else entry_paths
|
|
290
|
+
for entry_path in paths:
|
|
291
|
+
if entry_path == ArchiveEntry.INTEGRITY.value:
|
|
292
|
+
continue
|
|
293
|
+
descriptor = inventory.get(entry_path)
|
|
294
|
+
if not isinstance(descriptor, Mapping):
|
|
295
|
+
raise InvalidArchiveStructureError(f"Archive integrity inventory omits '{entry_path}'.")
|
|
296
|
+
expected_length = descriptor.get("stored_length")
|
|
297
|
+
if expected_length is None:
|
|
298
|
+
raise InvalidArchiveStructureError(
|
|
299
|
+
f"Archive integrity inventory entry '{entry_path}' has no stored length."
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
decoded_length = decode_schema_int(expected_length, non_negative=True)
|
|
303
|
+
except CanonicalisationError as error:
|
|
304
|
+
raise InvalidArchiveStructureError(
|
|
305
|
+
f"Archive integrity inventory entry '{entry_path}' has an invalid stored length."
|
|
306
|
+
) from error
|
|
307
|
+
actual_length = len(archive.read_entry(entry_path))
|
|
308
|
+
if actual_length != decoded_length:
|
|
309
|
+
raise IntegrityMismatchError(
|
|
310
|
+
f"Archive entry '{entry_path}' has a stored length mismatch: "
|
|
311
|
+
f"expected {decoded_length}; observed {actual_length}.",
|
|
312
|
+
layer="storage",
|
|
313
|
+
)
|
|
314
|
+
expected_storage = descriptor.get("storage_digest")
|
|
315
|
+
if (
|
|
316
|
+
entry_path in archive.entry_paths
|
|
317
|
+
and descriptor.get("kind")
|
|
318
|
+
in {
|
|
319
|
+
"fixed",
|
|
320
|
+
"source",
|
|
321
|
+
"payload",
|
|
322
|
+
"revision-log",
|
|
323
|
+
"environment-record",
|
|
324
|
+
"source-bundle",
|
|
325
|
+
}
|
|
326
|
+
and not isinstance(expected_storage, str)
|
|
327
|
+
):
|
|
328
|
+
raise InvalidArchiveStructureError(
|
|
329
|
+
f"Archive integrity inventory entry '{entry_path}' has no storage digest."
|
|
330
|
+
)
|
|
331
|
+
if expected_storage is None:
|
|
332
|
+
continue
|
|
333
|
+
observed_storage = calculate_storage_digest(
|
|
334
|
+
entry_path,
|
|
335
|
+
archive.read_entry(entry_path),
|
|
336
|
+
)
|
|
337
|
+
if observed_storage != expected_storage:
|
|
338
|
+
raise IntegrityMismatchError(
|
|
339
|
+
f"Archive entry '{entry_path}' has a storage digest mismatch: "
|
|
340
|
+
f"expected {expected_storage}; observed {observed_storage}.",
|
|
341
|
+
layer="storage",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _validate_integrity_record_digests(
|
|
346
|
+
archive: BoundedZipReader,
|
|
347
|
+
inventory: Mapping[str, Any],
|
|
348
|
+
integrity_data: Mapping[str, Any],
|
|
349
|
+
) -> bool:
|
|
350
|
+
"""Validate immutable record bodies and their digest index."""
|
|
351
|
+
records_document = _normalise_new_object_list(
|
|
352
|
+
archive.read_json(
|
|
353
|
+
ArchiveEntry.RECORDS,
|
|
354
|
+
typed_metadata_paths=_CANONICAL_OPERATION_PARAMETER_PATHS,
|
|
355
|
+
),
|
|
356
|
+
"records",
|
|
357
|
+
"records",
|
|
358
|
+
)
|
|
359
|
+
record_bodies: dict[str, dict[str, Any]] = {}
|
|
360
|
+
complete_coverage = True
|
|
361
|
+
for descriptor in records_document:
|
|
362
|
+
record_id = descriptor.get("record_id")
|
|
363
|
+
if not isinstance(record_id, str):
|
|
364
|
+
raise InvalidArchiveStructureError("Archive records require a string record_id.")
|
|
365
|
+
body = record_body(descriptor)
|
|
366
|
+
record_bodies[record_id] = body
|
|
367
|
+
expected_digest = descriptor.get("record_digest")
|
|
368
|
+
observed_digest = record_digest(body)
|
|
369
|
+
if expected_digest != observed_digest:
|
|
370
|
+
raise IntegrityMismatchError(
|
|
371
|
+
f"Record '{record_id}' has a record digest mismatch: expected "
|
|
372
|
+
f"{expected_digest}; observed {observed_digest}.",
|
|
373
|
+
layer="record",
|
|
374
|
+
)
|
|
375
|
+
inventory_descriptor = inventory.get(f"records/{record_id}")
|
|
376
|
+
if not isinstance(inventory_descriptor, Mapping):
|
|
377
|
+
complete_coverage = False
|
|
378
|
+
continue
|
|
379
|
+
if inventory_descriptor.get("record_digest") != observed_digest:
|
|
380
|
+
raise IntegrityMismatchError(
|
|
381
|
+
f"Record '{record_id}' disagrees with its inventory digest.",
|
|
382
|
+
layer="record",
|
|
383
|
+
)
|
|
384
|
+
record_digests = integrity_data.get("record_digests")
|
|
385
|
+
expected_record_digests = {
|
|
386
|
+
record_id: record_digest(body) for record_id, body in sorted(record_bodies.items())
|
|
387
|
+
}
|
|
388
|
+
if not isinstance(record_digests, Mapping) or dict(record_digests) != expected_record_digests:
|
|
389
|
+
raise IntegrityMismatchError(
|
|
390
|
+
"Archive record digest index does not match records.json.",
|
|
391
|
+
layer="record",
|
|
392
|
+
)
|
|
393
|
+
return complete_coverage
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _validate_integrity_revision_roots(
|
|
397
|
+
documents: ArchiveDocuments,
|
|
398
|
+
integrity_data: Mapping[str, Any],
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Validate revision snapshots, roots, head identity, and field bindings."""
|
|
401
|
+
revision_bodies = integrity_data.get("revision_bodies")
|
|
402
|
+
revision_roots = integrity_data.get("revision_roots")
|
|
403
|
+
if not isinstance(revision_bodies, Mapping) or not isinstance(
|
|
404
|
+
revision_roots,
|
|
405
|
+
Mapping,
|
|
406
|
+
):
|
|
407
|
+
raise InvalidArchiveStructureError("Archive integrity revision roots are incomplete.")
|
|
408
|
+
revision_ids = [str(revision.revision_id) for revision in documents.revisions]
|
|
409
|
+
if set(revision_bodies) != set(revision_ids) or set(revision_roots) != set(revision_ids):
|
|
410
|
+
raise InvalidArchiveStructureError(
|
|
411
|
+
"Archive integrity revision snapshots do not cover revision history."
|
|
412
|
+
)
|
|
413
|
+
inventory = integrity_data.get("inventory")
|
|
414
|
+
revision_log = (
|
|
415
|
+
inventory.get(ArchiveEntry.REVISIONS.value) if isinstance(inventory, Mapping) else None
|
|
416
|
+
)
|
|
417
|
+
if not isinstance(revision_log, Mapping) or revision_log.get("revision_ids") != (revision_ids):
|
|
418
|
+
raise IntegrityMismatchError(
|
|
419
|
+
"Archive revision-log inventory does not match revision history.",
|
|
420
|
+
layer="revision",
|
|
421
|
+
)
|
|
422
|
+
for revision in documents.revisions:
|
|
423
|
+
revision_id = str(revision.revision_id)
|
|
424
|
+
body = revision_bodies.get(revision_id)
|
|
425
|
+
if not isinstance(body, Mapping):
|
|
426
|
+
raise InvalidArchiveStructureError(
|
|
427
|
+
f"Archive integrity omits revision snapshot '{revision_id}'."
|
|
428
|
+
)
|
|
429
|
+
expected_root = revision_digest(body)
|
|
430
|
+
stored_root = revision_roots.get(revision_id)
|
|
431
|
+
if stored_root != expected_root or revision.revision_root != expected_root:
|
|
432
|
+
raise IntegrityMismatchError(
|
|
433
|
+
f"Revision '{revision_id}' has a revision root mismatch: expected "
|
|
434
|
+
f"{expected_root}; observed {stored_root}.",
|
|
435
|
+
layer="revision",
|
|
436
|
+
)
|
|
437
|
+
_validate_revision_body_identity(revision, body)
|
|
438
|
+
head_revision_id = integrity_data.get("head_revision_id")
|
|
439
|
+
if head_revision_id != revision_ids[-1]:
|
|
440
|
+
raise InvalidArchiveStructureError(
|
|
441
|
+
"Archive integrity head revision does not match revision history."
|
|
442
|
+
)
|
|
443
|
+
manifest_body = integrity_data.get("manifest_body")
|
|
444
|
+
if manifest_body != revision_bodies[revision_ids[-1]]:
|
|
445
|
+
raise IntegrityMismatchError(
|
|
446
|
+
"Archive integrity manifest body does not match the head snapshot.",
|
|
447
|
+
layer="revision",
|
|
448
|
+
)
|
|
449
|
+
head_body = revision_bodies[revision_ids[-1]]
|
|
450
|
+
if head_body.get("inventory") != integrity_data.get("inventory"):
|
|
451
|
+
raise IntegrityMismatchError(
|
|
452
|
+
"Archive integrity inventory does not match the head snapshot.",
|
|
453
|
+
layer="revision",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _validate_integrity_inventory_keys(
|
|
458
|
+
archive: BoundedZipReader,
|
|
459
|
+
manifest: Manifest,
|
|
460
|
+
documents: ArchiveDocuments,
|
|
461
|
+
integrity_data: Mapping[str, Any],
|
|
462
|
+
) -> bool:
|
|
463
|
+
"""Require one inventory item for each physical and logical archive item."""
|
|
464
|
+
inventory = integrity_data.get("inventory")
|
|
465
|
+
if not isinstance(inventory, Mapping):
|
|
466
|
+
raise InvalidArchiveStructureError("Archive integrity inventory is incomplete.")
|
|
467
|
+
expected = {
|
|
468
|
+
entry_path
|
|
469
|
+
for entry_path in archive.entry_paths
|
|
470
|
+
if entry_path != ArchiveEntry.INTEGRITY.value
|
|
471
|
+
}
|
|
472
|
+
record_ids = {str(record.record_id) for record in documents.checkpoints}
|
|
473
|
+
expected.update({f"records/{record_id}" for record_id in record_ids})
|
|
474
|
+
expected.update({f"provenance/{record_id}" for record_id in record_ids})
|
|
475
|
+
expected.update({f"revisions/{revision.revision_id}" for revision in documents.revisions})
|
|
476
|
+
expected.add(ArchiveEntry.REVISIONS.value)
|
|
477
|
+
revision_bodies = integrity_data.get("revision_bodies", {})
|
|
478
|
+
if isinstance(revision_bodies, Mapping):
|
|
479
|
+
for revision_id, body in revision_bodies.items():
|
|
480
|
+
if not isinstance(body, Mapping):
|
|
481
|
+
continue
|
|
482
|
+
selections = body.get("active_selections", {})
|
|
483
|
+
if isinstance(selections, Mapping):
|
|
484
|
+
expected.update(
|
|
485
|
+
f"active/{revision_id}/{checkpoint_id}" for checkpoint_id in selections
|
|
486
|
+
)
|
|
487
|
+
for source in manifest.sources.values():
|
|
488
|
+
source_id = source.source_id
|
|
489
|
+
if source_id is not None and source.entry is None:
|
|
490
|
+
expected.add(f"source-records/{source_id}")
|
|
491
|
+
expected.update(f"features/required/{feature}" for feature in manifest.required_features)
|
|
492
|
+
if set(inventory) != expected:
|
|
493
|
+
missing = sorted(expected - set(inventory))
|
|
494
|
+
extra = sorted(set(inventory) - expected)
|
|
495
|
+
physical_expected = {
|
|
496
|
+
entry_path for entry_path in expected if entry_path in archive.entry_paths
|
|
497
|
+
}
|
|
498
|
+
missing_physical = sorted(set(missing) & physical_expected)
|
|
499
|
+
if missing_physical or extra:
|
|
500
|
+
detail = f"missing={missing!r}, extra={extra!r}"
|
|
501
|
+
raise InvalidArchiveStructureError(
|
|
502
|
+
f"Archive integrity inventory is not closed: {detail}."
|
|
503
|
+
)
|
|
504
|
+
return False
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _validate_new_metadata_documents(documents: ArchiveDocuments) -> None:
|
|
509
|
+
"""Validate typed metadata before integrity checks consume the document."""
|
|
510
|
+
for fields in documents.metadata_by_run.values():
|
|
511
|
+
if not isinstance(fields, Mapping):
|
|
512
|
+
raise InvalidArchiveStructureError("Canonical metadata by run must be an object.")
|
|
513
|
+
for field_data in fields.values():
|
|
514
|
+
_deserialise_metadata_field(field_data)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _validate_revision_body_identity(
|
|
518
|
+
revision: Revision,
|
|
519
|
+
body: Mapping[str, Any],
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Validate the revision fields mirrored in its canonical snapshot."""
|
|
522
|
+
expected_fields = {
|
|
523
|
+
"revision_id": str(revision.revision_id),
|
|
524
|
+
"archive_id": str(revision.archive_id),
|
|
525
|
+
"parent_revision_id": (
|
|
526
|
+
str(revision.parent_revision_id) if revision.parent_revision_id is not None else None
|
|
527
|
+
),
|
|
528
|
+
"parent_root": revision.parent_root,
|
|
529
|
+
"active_selections": dict(revision.active_selections),
|
|
530
|
+
"events": [dict(event) for event in revision.events],
|
|
531
|
+
"created_at": revision.created_at.isoformat(),
|
|
532
|
+
}
|
|
533
|
+
for field_name, expected in expected_fields.items():
|
|
534
|
+
if body.get(field_name) != expected:
|
|
535
|
+
raise IntegrityMismatchError(
|
|
536
|
+
f"Revision '{revision.revision_id}' body field '{field_name}' "
|
|
537
|
+
"does not match the revision log.",
|
|
538
|
+
layer="revision",
|
|
539
|
+
)
|