#!/usr/bin/env python3
"""Verify a ReelGrove browser release before loading it into local Docker.

Standard-library only. The release manifest must come from a trusted publisher;
its hashes verify integrity, not publisher authentication.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import tarfile
import tempfile

DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z")
BLOB = re.compile(r"blobs/sha256/[0-9a-f]{64}\Z")
MAX_ARCHIVE = 2 << 30
MAX_TAR = 4 << 30


def json_value(raw):
    def unique(pairs):
        value = {}
        for key, item in pairs:
            if key in value:
                raise ValueError("duplicate JSON field")
            value[key] = item
        return value
    return json.loads(raw, object_pairs_hook=unique)


def file_hash(path):
    h = hashlib.sha256()
    with open(path, 'rb') as source:
        while block := source.read(1 << 20):
            h.update(block)
    return h.hexdigest()


def inspect_archive(path):
    """Hash every OCI blob without extracting any archive member to disk."""
    blobs, metadata, names, total = {}, {}, set(), 0
    with tarfile.open(path, 'r:*') as archive:
        for member in archive:
            name = member.name.rstrip('/')
            if name in names:
                raise ValueError('duplicate archive member')
            names.add(name)
            if len(names) > 128:
                raise ValueError('too many archive entries')
            if member.isdir() and name in ('blobs', 'blobs/sha256'):
                continue
            if not member.isfile() or member.size > MAX_TAR:
                raise ValueError('unsupported archive member')
            total += member.size
            if total > MAX_TAR:
                raise ValueError('archive exceeds limit')
            if not BLOB.fullmatch(name) and name not in ('index.json', 'manifest.json', 'oci-layout'):
                raise ValueError('unknown archive member')
            source = archive.extractfile(member)
            digest, small = hashlib.sha256(), bytearray()
            while block := source.read(1 << 20):
                digest.update(block)
                if member.size <= 1 << 20:
                    small.extend(block)
            sha = digest.hexdigest()
            if BLOB.fullmatch(name):
                if sha != name.rsplit('/', 1)[1]:
                    raise ValueError('OCI blob hash mismatch')
                blobs['sha256:' + sha] = (member.size, bytes(small))
            else:
                if member.size > 1 << 20:
                    raise ValueError('oversized metadata')
                metadata[name] = json_value(small)
    index = metadata['index.json']
    if metadata['oci-layout'] != {'imageLayoutVersion': '1.0.0'} or index['schemaVersion'] != 2 or len(index['manifests']) != 1:
        raise ValueError('expected one OCI image')
    manifest_ref = index['manifests'][0]
    manifest_raw = blobs[manifest_ref['digest']]
    if manifest_raw[0] != manifest_ref['size']:
        raise ValueError('manifest size mismatch')
    manifest = json_value(manifest_raw[1])
    if manifest['schemaVersion'] != 2:
        raise ValueError('unknown image schema')
    config_ref = manifest['config']
    config_blob = blobs[config_ref['digest']]
    if config_blob[0] != config_ref['size']:
        raise ValueError('configuration size mismatch')
    config = json_value(config_blob[1])
    layers = []
    for layer in manifest['layers']:
        if layer['mediaType'] != 'application/vnd.oci.image.layer.v1.tar' or blobs[layer['digest']][0] != layer['size']:
            raise ValueError('unsupported or missing layer')
        layers.append(layer['digest'])
    if config['rootfs'] != {'type': 'layers', 'diff_ids': layers}:
        raise ValueError('configuration layers differ')
    docker_manifest = metadata['manifest.json']
    if len(docker_manifest) != 1 or docker_manifest[0]['Config'] != 'blobs/sha256/' + config_ref['digest'][7:] or docker_manifest[0]['Layers'] != ['blobs/sha256/' + x[7:] for x in layers] or docker_manifest[0].get('RepoTags'):
        raise ValueError('Docker and OCI inventory differ, or archive assigns a tag')
    if config['os'] != 'linux' or config['architecture'] != 'amd64' or config['config']['User'] != '65532:65532' or config['config']['Entrypoint'] != ['/usr/local/bin/releasebrowser']:
        raise ValueError('unexpected browser image configuration')
    return {'configDigest': config_ref['digest'], 'manifestDigest': manifest_ref['digest'],
            'diffIDs': layers, 'os': config['os'], 'architecture': config['architecture']}


def document(directory, spec):
    name = spec['name']
    if not isinstance(name, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}', name):
        raise ValueError('invalid artifact name')
    path = directory / name
    if path.is_symlink() or not path.is_file() or path.stat().st_size != spec['bytes'] or not 0 < spec['bytes'] <= MAX_ARCHIVE or file_hash(path) != spec['sha256']:
        raise ValueError('artifact checksum or length mismatch: ' + name)
    return path


def verify(directory):
    raw = (directory / 'manifest.json').read_bytes()
    if len(raw) > 65536:
        raise ValueError('manifest too large')
    manifest = json_value(raw)
    if manifest['schemaVersion'] != 1 or manifest['tool'] != 'reelgrove-browser' or not re.fullmatch('[0-9a-f]{40}', manifest['runtimeSourceCommit']):
        raise ValueError('unsupported release manifest')
    archive = document(directory, manifest['archive'])
    document(directory, manifest['notices'])
    if 'loader' in manifest:
        document(directory, manifest['loader'])
    if inspect_archive(archive) != manifest['image']:
        raise ValueError('image differs from release manifest')
    return manifest, archive


def load(directory):
    manifest, archive = verify(directory)
    # Hold our own immutable verified copy across Docker's file read. Shared
    # downloads can otherwise be replaced after checksum verification.
    with tempfile.TemporaryDirectory(prefix='reelgrove-browser-load-') as stage:
        frozen = Path(stage) / 'image.tar.gz'
        with archive.open('rb') as source, frozen.open('xb') as target:
            while block := source.read(1 << 20):
                target.write(block)
        if file_hash(frozen) != manifest['archive']['sha256']:
            raise ValueError('archive changed before load')
        output = subprocess.check_output(['docker', 'image', 'load', '--input', str(frozen)], timeout=600, text=True)
        ids = re.findall(r'^Loaded image ID: (sha256:[0-9a-f]{64})$', output, re.M)
        if len(ids) != 1:
            raise ValueError('Docker did not report one exact image identity')
        resolved = ids[0]
        saved = Path(stage) / 'loaded.tar'
        subprocess.run(['docker', 'image', 'save', '--output', str(saved), resolved], check=True, timeout=600)
        actual = inspect_archive(saved)
        if resolved not in (actual['configDigest'], actual['manifestDigest']):
            raise ValueError('daemon identity is neither verified config nor manifest digest')
        # Docker stores may rewrite OCI manifest packaging. The immutable
        # configuration commits the ordered uncompressed filesystem layers.
        expected = manifest['image']
        if any(actual[k] != expected[k] for k in ('configDigest', 'diffIDs', 'os', 'architecture')):
            raise ValueError('loaded image configuration/layers differ from release')
        details = json_value(subprocess.check_output(['docker', 'image', 'inspect', resolved], timeout=30))
        if len(details) != 1 or details[0]['Id'] != resolved or details[0]['RootFS']['Layers'] != expected['diffIDs']:
            raise ValueError('loaded image inspection differs')
    return resolved



def verify_sources(directory):
    manifest, _ = verify(directory)
    archive = document(directory, manifest['correspondingSource']['archive'])
    index_path = document(directory, manifest['correspondingSource']['index'])
    index = json_value(index_path.read_bytes())
    if index['runtimeSourceCommit'] != manifest['runtimeSourceCommit']:
        raise ValueError('source inventory is for another runtime')
    expected = {row['path']: row for row in index['files']}
    if len(expected) != len(index['files']) or len(expected) > 10000:
        raise ValueError('invalid source inventory')
    observed = set()
    with tarfile.open(archive, 'r|gz') as tar:
        for member in tar:
            if member.name not in expected or member.name in observed or not member.isfile():
                raise ValueError('unexpected source archive member')
            spec = expected[member.name]
            if member.size != spec['bytes']:
                raise ValueError('source archive length mismatch')
            h = hashlib.sha256()
            source = tar.extractfile(member)
            while chunk := source.read(1 << 20):
                h.update(chunk)
            if h.hexdigest() != spec['sha256']:
                raise ValueError('source archive checksum mismatch')
            observed.add(member.name)
    if observed != set(expected):
        raise ValueError('missing corresponding-source member')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('command', choices=['verify', 'verify-sources', 'load'])
    parser.add_argument('directory', type=Path)
    args = parser.parse_args()
    try:
        if args.command == 'load':
            print(load(args.directory))
        elif args.command == 'verify-sources':
            verify_sources(args.directory)
            print('Verified complete corresponding-source inventory')
        else:
            manifest, _ = verify(args.directory)
            print('Verified ' + manifest['runtimeSourceCommit'] + ' ' + manifest['image']['configDigest'])
    except (ValueError, KeyError, TypeError, OSError, tarfile.TarError, subprocess.SubprocessError) as exc:
        parser.exit(2, 'browser-image: ' + str(exc) + '\n')


if __name__ == '__main__':
    main()
