"""Read-only, bounded PE fingerprint audit. Never produces a loadable RVA profile.

Uses original function bytes, masks only encoded RIP/branch/VA references, then
requires the remaining bytes to match. This is evidence for manual migration;
neither a matching signature nor the generated address candidates approves hooks.
"""
from __future__ import annotations

import argparse
import bisect
import collections
import datetime as dt
import hashlib
import json
from pathlib import Path
import re
import struct

import capstone as cs
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RIP
import pefile


class Image:
    def __init__(self, path: Path):
        self.path = path
        self.raw = path.read_bytes()
        self.pe = pefile.PE(data=self.raw, fast_load=True)
        self.base = self.pe.OPTIONAL_HEADER.ImageBase
        self.size = self.pe.OPTIONAL_HEADER.SizeOfImage
        self.text = next(s for s in self.pe.sections if s.Name.startswith(b'.text'))
        self.text_bytes = self.text.get_data()
        directory = self.pe.OPTIONAL_HEADER.DATA_DIRECTORY[3]
        data = self.pe.get_data(directory.VirtualAddress, directory.Size)
        self.functions = sorted(struct.iter_unpack('<III', data))
        self.starts = [x[0] for x in self.functions]
        self.unwind_roots = {}
        grouped = collections.defaultdict(list)
        for entry in self.functions:
            current = entry
            for _ in range(16):
                header = self.data(current[2], 4)
                if not (header[0] >> 3) & 4:
                    break
                chained = current[2] + 4 + ((header[2] + 1) & ~1) * 2
                current = struct.unpack('<III', self.data(chained, 12))
            else:
                raise ValueError('unwind chain exceeded bound')
            self.unwind_roots[entry[0]] = current
            grouped[current[0]].append(entry)
        self.contiguous_unwind_spans = {}
        for root, pieces in grouped.items():
            original = self.unwind_roots[pieces[0][0]]
            end = original[1]
            for begin, finish, unwind in sorted(pieces):
                if begin <= end + 16:
                    end = max(end, finish)
                else:
                    break  # Never swallow an unrelated gap or distant cold block.
            self.contiguous_unwind_spans[root] = (root, end, original[2])
        self.dis = cs.Cs(cs.CS_ARCH_X86, cs.CS_MODE_64)
        self.dis.detail = True

    def data(self, rva, size):
        return self.pe.get_data(rva, size)

    def section(self, rva):
        s = self.pe.get_section_by_rva(rva)
        return s.Name.rstrip(b'\0').decode() if s else None

    def function(self, rva):
        at = bisect.bisect_right(self.starts, rva) - 1
        if at >= 0 and self.functions[at][0] <= rva < self.functions[at][1]:
            region = self.functions[at]
            root = self.unwind_roots[region[0]][0]
            span = self.contiguous_unwind_spans[root]
            return span if span[0] <= rva < span[1] else region
        return None

    def instructions(self, rva, size):
        return list(self.dis.disasm(self.data(rva, size), self.base + rva))

    def metadata(self):
        p = self.pe
        p.parse_data_directories(directories=[2])
        versions = {}
        for group in getattr(p, 'FileInfo', []):
            for info in group:
                if getattr(info, 'Key', b'') == b'StringFileInfo':
                    for table in info.StringTable:
                        versions.update({k.decode(errors='replace'): v.decode(errors='replace') for k, v in table.entries.items()})
        return {
            'path': str(self.path), 'length': len(self.raw),
            'sha256': hashlib.sha256(self.raw).hexdigest(), 'sha1': hashlib.sha1(self.raw).hexdigest(),
            'peTimestampUtc': dt.datetime.fromtimestamp(p.FILE_HEADER.TimeDateStamp, dt.timezone.utc).isoformat(),
            'machine': hex(p.FILE_HEADER.Machine), 'characteristics': hex(p.FILE_HEADER.Characteristics),
            'imageBase': hex(self.base), 'sizeOfImage': hex(self.size),
            'entryPoint': hex(p.OPTIONAL_HEADER.AddressOfEntryPoint),
            'linker': [p.OPTIONAL_HEADER.MajorLinkerVersion, p.OPTIONAL_HEADER.MinorLinkerVersion],
            'dllCharacteristics': hex(p.OPTIONAL_HEADER.DllCharacteristics),
            'checksum': hex(p.OPTIONAL_HEADER.CheckSum), 'functionRanges': len(self.functions),
            'contiguousUnwindSpans': len(self.contiguous_unwind_spans),
            'version': {k: versions[k] for k in ('FileVersion', 'ProductVersion', 'CompanyName', 'FileDescription') if k in versions},
            'certificateDirectorySize': p.OPTIONAL_HEADER.DATA_DIRECTORY[4].Size,
            'sections': [{'name': s.Name.rstrip(b'\0').decode(), 'rva': hex(s.VirtualAddress),
                          'virtualSize': hex(s.Misc_VirtualSize), 'rawOffset': hex(s.PointerToRawData),
                          'rawSize': hex(s.SizeOfRawData), 'characteristics': hex(s.Characteristics),
                          'sha256': hashlib.sha256(s.get_data()).hexdigest()} for s in p.sections]
        }


def signature(image, rva, limit):
    fn = image.function(rva)
    length = min(limit, fn[1] - rva) if fn else min(limit, 96)
    instructions = image.instructions(rva, length)
    if not fn:
        for at, ins in enumerate(instructions):
            if ins.group(cs.CS_GRP_RET):
                instructions = instructions[:at + 1]
                break
    if not instructions:
        return None
    length = instructions[-1].address + instructions[-1].size - image.base - rva
    raw = image.data(rva, length)
    mask = bytearray(b'\xff' * len(raw))
    for ins in instructions:
        offset = ins.address - image.base - rva
        local_jump_rva = fn and any(o.type == X86_OP_MEM and o.mem.index != 0 and
                                   fn[0] <= o.mem.disp < fn[1] + 512 for o in ins.operands)
        if any(o.type == X86_OP_MEM and o.mem.base == X86_REG_RIP for o in ins.operands) or local_jump_rva:
            mask[offset + ins.disp_offset:offset + ins.disp_offset + ins.disp_size] = b'\0' * ins.disp_size
        relative = ins.group(cs.CS_GRP_CALL) or ins.group(cs.CS_GRP_JUMP)
        absolute = any(o.type == X86_OP_IMM and image.base <= o.imm < image.base + image.size for o in ins.operands)
        if relative or absolute:
            mask[offset + ins.imm_offset:offset + ins.imm_offset + ins.imm_size] = b'\0' * ins.imm_size
    runs = []
    start = None
    for at, value in enumerate([*mask, 0]):
        if value and start is None:
            start = at
        elif not value and start is not None:
            runs.append((start, at - start))
            start = None
    anchor, anchor_size = max(runs, key=lambda row: row[1], default=(0, 0))
    return raw, bytes(mask), anchor, anchor_size, instructions


def match(old, new, rva, limit=384):
    sig = signature(old, rva, limit)
    if not sig:
        return {'status': 'not-disassembled'}
    raw, mask, anchor, size, instructions = sig
    if size < 5:
        return {'status': 'insufficient-signature', 'length': len(raw), 'anchorLength': size}
    needle = raw[anchor:anchor + size]
    fixed = [at for at, v in enumerate(mask) if v]
    candidates = []
    pos = 0
    anchor_hits = 0
    while True:
        pos = new.text_bytes.find(needle, pos)
        if pos < 0:
            break
        anchor_hits += 1
        beginning = pos - anchor
        if beginning >= 0 and beginning + len(raw) <= len(new.text_bytes):
            other = new.text_bytes[beginning:beginning + len(raw)]
            if all(raw[i] == other[i] for i in fixed):
                candidates.append(new.text.VirtualAddress + beginning)
                if len(candidates) > 64:
                    break
        pos += 1
    result = {'status': 'unique' if len(candidates) == 1 else 'ambiguous' if candidates else 'no-match',
              'length': len(raw), 'anchorLength': size, 'anchorHits': anchor_hits,
              'maskedBytes': len(mask) - sum(bool(v) for v in mask),
              'fingerprintSha256': hashlib.sha256(bytes(a & b for a, b in zip(raw, mask))).hexdigest(),
              'maskSha256': hashlib.sha256(mask).hexdigest(),
              'oldWindowSha256': hashlib.sha256(raw).hexdigest(),
              'candidates': [hex(x) for x in candidates[:64]]}
    fn = old.function(rva)
    if fn:
        result['oldFunctionRange'] = [hex(fn[0]), hex(fn[1])]
    if len(candidates) == 1:
        target = candidates[0]
        result['newRva'] = hex(target)
        result['delta'] = target - rva
        target_fn = new.function(target)
        result['newAtFunctionEntry'] = bool(target_fn and target_fn[0] == target)
        result['oldAtFunctionEntry'] = bool(fn and fn[0] == rva)
        result['unmaskedBytesIdentical'] = old.data(rva, len(raw)) == new.data(target, len(raw))
        result['newWindowSha256'] = hashlib.sha256(new.data(target, len(raw))).hexdigest()
    return result


def references(old, new, rva, target, length):
    before = old.instructions(rva, length)
    after = new.instructions(target, length)
    result = []
    if len(before) != len(after):
        return result
    for a, b in zip(before, after):
        if a.mnemonic != b.mnemonic or a.size != b.size:
            return []
        for left, right in zip(a.operands, b.operands):
            pair = None
            if left.type == right.type == X86_OP_MEM and left.mem.base == right.mem.base == X86_REG_RIP:
                pair = a.address + a.size + left.mem.disp - old.base, b.address + b.size + right.mem.disp - new.base
            elif left.type == right.type == X86_OP_IMM and old.base <= left.imm < old.base + old.size and new.base <= right.imm < new.base + new.size:
                pair = left.imm - old.base, right.imm - new.base
            if pair:
                result.append((pair[0], pair[1], rva, a.address - old.base))
    return result


def proto_fields(data):
    def varint(at):
        value = 0
        for shift in range(0, 70, 7):
            byte = data[at]; at += 1
            value |= (byte & 127) << shift
            if byte < 128:
                return value, at
        raise ValueError('oversized varint')
    at = 0
    while at < len(data):
        tag, at = varint(at); field, wire = tag >> 3, tag & 7
        if wire == 0:
            value, at = varint(at)
        elif wire == 2:
            length, at = varint(at); value = data[at:at + length]; at += length
        elif wire in (1, 5):
            width = 8 if wire == 1 else 4; value = data[at:at + width]; at += width
        else:
            raise ValueError(f'unsupported wire type {wire}')
        yield field, wire, value


def depot_row(path):
    raw = path.read_bytes()
    magic, size = struct.unpack_from('<II', raw)
    if magic != 0x71F617D0:
        raise ValueError('unexpected Steam manifest payload header')
    for field, wire, value in proto_fields(raw[8:8 + size]):
        if field != 1 or wire != 2:
            continue
        row = dict((f, v) for f, w, v in proto_fields(value) if f != 6)
        name = row.get(1, b'').decode(errors='replace')
        if name.endswith('elite-dangerous-odyssey-64\\EliteDangerous64.exe'):
            return {'manifestPath': str(path), 'manifestSha256': hashlib.sha256(raw).hexdigest(),
                    'file': name, 'size': row.get(2), 'field4Hex': row.get(4, b'').hex(),
                    'contentSha1': row.get(5, b'').hex(), 'note': 'Local cached manifest; no external signature verification performed.'}
    return None


def needed_rip_references(image, targets):
    """Candidate instruction offsets, validated with Capstone before using."""
    output = collections.defaultdict(list)
    pattern = (rb'(?:[\x40-\x4f]?[\x8d\x8b]|[\x40-\x4f]?[\x66\xf2\xf3]?\x0f[\x10\x28\x6f])'
               rb'[\x05\x0d\x15\x1d\x25\x2d\x35\x3d]....')
    for hit in re.finditer(pattern, image.text_bytes, re.S):
        rva = image.text.VirtualAddress + hit.start()
        raw = hit.group()
        target = rva + len(raw) + struct.unpack_from('<i', raw, len(raw) - 4)[0]
        if target not in targets or len(output[target]) >= 8:
            continue
        instruction = image.instructions(rva, len(raw))
        function = image.function(rva)
        if function and len(instruction) == 1 and instruction[0].size == len(raw) and any(
                op.type == X86_OP_MEM and op.mem.base == X86_REG_RIP for op in instruction[0].operands):
            output[target].append((function[0], rva))
    return output


def full_needed_spans(old, new, matches):
    result = []
    for key, row in matches.items():
        if 'source' not in row or row['status'] != 'unique':
            continue
        before, after = int(key, 16), int(row['newRva'], 16)
        old_fn, new_fn = old.function(before), new.function(after)
        check = {'oldRva': key, 'newRva': row['newRva']}
        if not old_fn or not new_fn or old_fn[0] != before or new_fn[0] != after:
            check['status'] = 'not-pdata-entry'
        else:
            old_length, new_length = old_fn[1] - before, new_fn[1] - after
            check.update(oldPdataSpanLength=old_length, newPdataSpanLength=new_length)
            if old_length != new_length:
                check['status'] = 'pdata-span-length-changed'
            elif old_length > 16384:
                check['status'] = 'over-bound'
            else:
                sig = signature(old, before, old_length)
                raw, mask, *_ = sig
                actual = new.data(after, len(raw))
                check['status'] = 'normalised-pdata-span-match' if len(raw) == old_length and all(
                    a == b for a, b, keep in zip(raw, actual, mask) if keep) else 'needs-body-review'
        result.append(check)
    return result


def source_inventory():
    needed = collections.defaultdict(set)
    for path in Path('native/ManagedPlugin').glob('Navigation*.cs'):
        if 'Tests' in path.name:
            continue
        text = path.read_text(encoding='utf8')
        for value in re.finditer(r'(?:image|expectedImage)\s*\+\s*0x([\da-fA-F]+)', text):
            needed[int(value[1], 16)].add(path.name)
        for declaration in re.finditer(r'const nuint ([^;]+);', text):
            for value in re.finditer(r'\b(\w+)\s*=\s*0x([\da-fA-F]+)', declaration[1]):
                needed[int(value[2], 16)].add(path.name + ':' + value[1])
    # The appearance adapter selects these already-known tree headers through
    # a switch then adds the selected offset to image. They are not captured
    # by the literal `image + RVA` pattern, so inventory them explicitly.
    for rva in (0x5FDF460, 0x5FDF2A0, 0x5FDF100, 0x5FDF4E0,
                0x5FDF190, 0x5FDF330, 0x5FDF560, 0x5FDF3E0):
        needed[rva].add('Navigation.GalaxyAppearance.cs:luminosity-tree-switch')
    return needed


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--old', type=Path, default=Path('artifacts/ship-model-reference/client-copy/EliteDangerous64.exe'))
    parser.add_argument('--new', type=Path, default=Path('D:/SteamLibrary/steamapps/common/Elite Dangerous/Products/elite-dangerous-odyssey-64/EliteDangerous64.exe'))
    parser.add_argument('--output', type=Path, default=Path('artifacts/galaxy-client-compatibility/audit.json'))
    args = parser.parse_args()
    old, new = Image(args.old), Image(args.new)
    needed = source_inventory()
    # Additional known galaxy constructors/getters establish vtable and global
    # reference mappings. This disassembles only these bounded function slices.
    seeds = set(needed)
    for path in Path('artifacts/galaxy-query-research').rglob('solo-*.c'):
        value = re.fullmatch(r'solo-([\da-fA-F]+)', path.stem)
        if value:
            seeds.add(int(value[1], 16))
    seeds.update((0x380E410, 0x647EF0, 0x636A80, 0xA22280, 0x150DD0,
                  0x1D507A0, 0x1D53050, 0x1D5F600, 0x2175F70,
                  0x2600E90, 0x26055E0, 0x2600870, 0x26007D0))
    rip_refs = needed_rip_references(old, {rva for rva in needed if old.section(rva) != '.text'})
    for rows in rip_refs.values():
        seeds.update(fn for fn, instruction in rows)
    # Add a bounded number of concrete vtable functions for cross-checking the
    # whole slot relation rather than assuming a constant .rdata delta.
    tables = {}
    for rva in needed:
        if old.section(rva) != '.rdata':
            continue
        slots = {}
        for offset in range(0, 0x200, 8):
            target = int.from_bytes(old.data(rva + offset, 8), 'little') - old.base
            if old.section(target) != '.text':
                break
            slots[offset] = target
        if len(slots) >= 3:
            tables[rva] = slots
            seeds.update(slots.values())
    matches = {}
    inferred = collections.defaultdict(lambda: collections.defaultdict(list))
    for rva in sorted(seeds):
        if old.section(rva) != '.text':
            continue
        found = match(old, new, rva)
        found['oldRva'] = hex(rva)
        if rva in needed:
            found['source'] = sorted(needed[rva])
        matches[hex(rva)] = found
        if found['status'] == 'unique':
            for before, after, source, instruction in references(old, new, rva, int(found['newRva'], 16), found['length']):
                inferred[before][after].append({'function': hex(source), 'instruction': hex(instruction)})
    # References outside the signature window must independently retain the
    # same instruction encoding at the same function-relative offset. This is
    # explicitly a reference candidate, not verification of an entire body.
    for original_target, rows in rip_refs.items():
        for function, instruction in rows:
            matched = matches.get(hex(function), {})
            if matched.get('status') != 'unique':
                continue
            new_instruction = int(matched['newRva'], 16) + instruction - function
            before = old.instructions(instruction, 8)
            if not before:
                continue
            size = before[0].size
            check = signature(old, instruction, size)
            after = new.instructions(new_instruction, size)
            if not check or len(after) != 1 or before[0].mnemonic != after[0].mnemonic or before[0].size != after[0].size:
                continue
            raw, mask, *_ = check
            actual = new.data(new_instruction, size)
            if any(a != b for a, b, keep in zip(raw, actual, mask) if keep):
                continue
            for left, right in zip(before[0].operands, after[0].operands):
                if left.type == right.type == X86_OP_MEM and left.mem.base == right.mem.base == X86_REG_RIP:
                    target = new_instruction + size + right.mem.disp
                    evidence = {'function': hex(function), 'instruction': hex(instruction),
                                'method': 'same-offset-instruction-independent-match'}
                    if evidence not in inferred[original_target][target]:
                        inferred[original_target][target].append(evidence)
    table_checks = []
    new_rdata = next(s for s in new.pe.sections if s.Name.startswith(b'.rdata'))
    new_rdata_bytes = new_rdata.get_data()
    for table, slots in tables.items():
        known = {offset: int(matches[hex(target)]['newRva'], 16) for offset, target in slots.items()
                 if matches.get(hex(target), {}).get('status') == 'unique'}
        options = collections.defaultdict(set)
        for offset, target in known.items():
            needle = struct.pack('<Q', new.base + target)
            pos = 0
            while (pos := new_rdata_bytes.find(needle, pos)) >= 0:
                candidate = new_rdata.VirtualAddress + pos - offset
                options[candidate].add(offset)
                pos += 8
        consistent = []
        for candidate, witnesses in options.items():
            if len(witnesses) >= 2 and len(witnesses) == len(known):
                consistent.append((candidate, len(witnesses)))
        entry = {'oldVtable': hex(table), 'slotsExamined': len(slots), 'uniqueMappedSlots': len(known),
                 'status': 'consistent-slot-candidate' if len(consistent) == 1 else 'unresolved',
                 'candidates': [{'newVtable': hex(c), 'delta': c - table, 'matchingSlots': n} for c, n in consistent]}
        if len(consistent) == 1:
            candidate = consistent[0][0]
            inferred[table][candidate].append({'method': 'all-uniquely-mapped-vtable-slots', 'slots': len(known)})
            for offset, before in slots.items():
                after = int.from_bytes(new.data(candidate + offset, 8), 'little') - new.base
                if new.section(after) == '.text':
                    inferred[before][after].append({'method': 'contextual-vtable-slot',
                                                    'table': hex(table), 'slot': hex(offset)})
        table_checks.append(entry)
    string_checks = []
    for rva in needed:
        if old.section(rva) != '.rdata' or inferred.get(rva):
            continue
        raw = old.data(rva, 256).split(b'\0', 1)[0]
        if len(raw) < 8 or not all(32 <= b < 127 for b in raw):
            continue
        needle = raw + b'\0'
        pos = new_rdata_bytes.find(needle)
        if pos >= 0 and new_rdata_bytes.find(needle, pos + 1) < 0:
            target = new_rdata.VirtualAddress + pos
            witness = {'method': 'unique-complete-ascii-string', 'value': raw.decode('ascii')}
            inferred[rva][target].append(witness)
            string_checks.append({'oldRva': hex(rva), 'newRva': hex(target), **witness})
    contextual = []
    for key, row in matches.items():
        if row['status'] == 'unique':
            continue
        options = inferred.get(int(key, 16), {})
        candidates = []
        for target, evidence in options.items():
            check = signature(old, int(key, 16), 384)
            if not check:
                continue
            raw, mask, *_ = check
            actual = new.data(target, len(raw))
            if len(actual) == len(raw) and all(a == b for a, b, keep in zip(raw, actual, mask) if keep):
                candidates.append({'newRva': hex(target), 'delta': target - int(key, 16), 'evidence': evidence})
        if candidates:
            contextual.append({'oldRva': key, 'intrinsicStatus': row['status'],
                               'status': 'single-context-candidate' if len(candidates) == 1 else 'ambiguous-context',
                               'candidates': candidates})
    addresses = []
    for rva, sources in sorted(needed.items()):
        if old.section(rva) == '.text':
            continue
        options = inferred.get(rva, {})
        addresses.append({'oldRva': hex(rva), 'section': old.section(rva), 'source': sorted(sources),
                          'status': 'consistent-reference' if len(options) == 1 else 'conflicting-reference' if options else 'unresolved',
                          'candidates': [{'newRva': hex(target), 'delta': target - rva, 'references': refs} for target, refs in options.items()]})
    manifests = [depot_row(Path('I:/steam/depotcache') / name) for name in
                 ('359326_1091597132551610634.manifest', '359326_3767724865074145418.manifest')]
    meta_old, meta_new = old.metadata(), new.metadata()
    for item, metadata in zip(manifests, (meta_old, meta_new)):
        item['matchesFileSize'] = item['size'] == metadata['length']
        item['matchesContentSha1'] = item['contentSha1'] == metadata['sha1']
    lines = Path('I:/steam/logs/content_log.txt').read_text(encoding='utf8', errors='replace').splitlines()
    evidence = [line for line in lines if 'AppID 359320' in line and
                ('finished update' in line or 'starting commit' in line) and
                ('2026-10-20' in line or '2026-10-23' in line)]
    report = {
        'scope': 'Read-only offline candidates, not a compatibility approval or loadable profile. No game process started and no source hooks/hash guards changed.',
        'generatedUtc': dt.datetime.now(dt.timezone.utc).isoformat(),
        'old': meta_old, 'new': meta_new, 'localDepotEvidence': manifests,
        'sanitizedSteamUpdateLog': evidence,
        'method': 'Bounded original function slices <=384 bytes, encoded RIP/relative-branch/absolute-VA and function-local indexed jump-table RVA operands masked, remaining bytes exact. Full text searched for uniqueness. Short/ambiguous candidates remain unverified. Referenced addresses derived only from paired instruction positions. Additional contextual vtable/call candidates are reported separately, never promoted to intrinsic unique matches.',
        'counts': {'neededRvas': len(needed), 'functionSeeds': len(matches),
                   'functionStatuses': dict(collections.Counter(x['status'] for x in matches.values())),
                   'neededFunctionStatuses': dict(collections.Counter(x['status'] for x in matches.values() if 'source' in x)),
                   'uniqueFunctionDeltas': dict(collections.Counter(str(x['delta']) for x in matches.values() if x['status'] == 'unique')),
                   'dataStatuses': dict(collections.Counter(x['status'] for x in addresses))},
        'functions': matches, 'dataReferences': addresses, 'vtableChecks': table_checks,
        'stringChecks': string_checks,
        'contextualFunctionCandidates': contextual,
        'neededPdataSpanChecks': full_needed_spans(old, new, matches),
        'limitations': ['Signature windows do not verify complete function semantics or object layouts.',
                        'Internal call targets, vtable lengths/order, owner/thread/lifetime invariants and hook relocation ABI require independent review.',
                        'Local manifest/log consistency establishes local Steam provenance evidence, not independent authentication of the entire distribution.',
                        'Unresolved/ambiguous symbols must not be migrated by a global delta.']
    }
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf8')
    print(json.dumps({'output': str(args.output), 'oldVersion': meta_old['version'], 'newVersion': meta_new['version'],
                      'counts': report['counts'], 'depotHashesMatch': all(x['matchesContentSha1'] and x['matchesFileSize'] for x in manifests)}, indent=2))


if __name__ == '__main__':
    main()
