"""Portable recommendation bundles with validated, content-addressed dependencies.""" import hashlib import io import json from pathlib import Path import re import tarfile import tempfile from .advisor import replay from .store import atomic, canonical, get PATH = re.compile(r'data/(?:recommendations/[0-9a-f]{64}\.json|captures/[0-9a-f]{64}\.json|artifacts/[0-9a-f]{64})') LIMIT = 128 * 1024 * 1024 def references(value, key): if isinstance(value, dict): if key in value: yield value[key] for child in value.values(): yield from references(child, key) elif isinstance(value, list): for child in value: yield from references(child, key) def export_bundle(root, recommendation_id, output): root, output = Path(root), Path(output) files = {} visited = set() def collect(ident): if ident in visited: return visited.add(ident) snapshot = get(root, 'recommendations', ident) replay(root, ident) name = f'data/recommendations/{ident}.json' files[name] = (root/name).read_bytes() for capture_id in set(references(snapshot, 'capture_id')): receipt = get(root, 'captures', capture_id) name = f'data/captures/{capture_id}.json' files[name] = (root/name).read_bytes() name = f"data/artifacts/{receipt['content_sha256']}" blob = (root/name).read_bytes() if hashlib.sha256(blob).hexdigest() != receipt['content_sha256']: raise ValueError('artifact integrity mismatch') files[name] = blob for parent in set(references(snapshot, 'recommendation_id')): collect(parent) collect(recommendation_id) manifest = {'schema_version': 1, 'recommendation_id': recommendation_id, 'files': {name: hashlib.sha256(data).hexdigest() for name, data in sorted(files.items())}} files['manifest.json'] = canonical(manifest) buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode='w') as archive: for name, data in sorted(files.items()): member = tarfile.TarInfo(name) member.size = len(data) member.mode = 0o644 archive.addfile(member, io.BytesIO(data)) atomic(output, buffer.getvalue(), immutable=True) return manifest def import_bundle(root, bundle): files = {} total = 0 try: archive = tarfile.open(bundle, 'r:*') except tarfile.TarError as exc: raise ValueError('invalid evidence bundle') from exc with archive: for member in archive: if member.name in files or not member.isfile(): raise ValueError('duplicate or non-regular bundle member') if member.name != 'manifest.json' and not PATH.fullmatch(member.name): raise ValueError('invalid bundle member path') total += member.size if total > LIMIT or len(files) >= 10000: raise ValueError('bundle exceeds import limit') files[member.name] = archive.extractfile(member).read() if 'manifest.json' not in files: raise ValueError('bundle missing manifest') manifest = json.loads(files.pop('manifest.json')) if manifest.get('schema_version') != 1 or set(files) != set(manifest['files']): raise ValueError('bundle manifest mismatch') for name, blob in files.items(): if hashlib.sha256(blob).hexdigest() != manifest['files'][name]: raise ValueError('bundle checksum mismatch') with tempfile.TemporaryDirectory(prefix='radar-import-') as directory: staging = Path(directory) for name, blob in files.items(): atomic(staging/name, blob, immutable=True) stem = Path(name).stem if '/artifacts/' in name: if hashlib.sha256(blob).hexdigest() != stem: raise ValueError('artifact content ID mismatch') else: get(staging, name.split('/')[1], stem) replay(staging, manifest['recommendation_id']) # Validate every contained report, including parent recommendations. for name in files: if '/recommendations/' in name: snapshot = get(staging, 'recommendations', Path(name).stem) for capture_id in set(references(snapshot, 'capture_id')): receipt = get(staging, 'captures', capture_id) artifact = staging / 'data/artifacts' / receipt['content_sha256'] if hashlib.sha256(artifact.read_bytes()).hexdigest() != receipt['content_sha256']: raise ValueError('capture artifact integrity mismatch') for parent in set(references(snapshot, 'recommendation_id')): get(staging, 'recommendations', parent) replay(staging, Path(name).stem) # Refuse collisions before publishing anything into the target ledger. for name, blob in files.items(): destination = Path(root)/name if destination.exists() and destination.read_bytes() != blob: raise ValueError('destination contains conflicting evidence') for name, blob in files.items(): atomic(Path(root)/name, blob, immutable=True) return manifest