|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +"""Compare reviewed closure against regeneration; does not discover remote closure.""" |
| 4 | +import re |
| 5 | +import sys |
| 6 | +import yaml |
| 7 | + |
| 8 | + |
| 9 | +class UniqueLoader(yaml.SafeLoader): |
| 10 | + pass |
| 11 | + |
| 12 | + |
| 13 | +def unique_mapping(loader, node, deep=False): |
| 14 | + """Construct a YAML mapping, raising ValueError for duplicate keys. |
| 15 | +
|
| 16 | + Pass deep through to PyYAML's key and value construction. YAML construction |
| 17 | + errors and TypeError for unhashable keys propagate to the caller. |
| 18 | + """ |
| 19 | + mapping = {} |
| 20 | + for key_node, value_node in node.value: |
| 21 | + item = loader.construct_object(key_node, deep=deep) |
| 22 | + if item in mapping: |
| 23 | + raise ValueError(f'duplicate YAML key: {item!r}') |
| 24 | + mapping[item] = loader.construct_object(value_node, deep=deep) |
| 25 | + return mapping |
| 26 | + |
| 27 | + |
| 28 | +UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) |
| 29 | + |
| 30 | + |
| 31 | +def key(value): |
| 32 | + """Normalize an owner/repository@ref key, preserving the ref's case. |
| 33 | +
|
| 34 | + Raise ValueError if the reference is not in the supported form. |
| 35 | + """ |
| 36 | + if not isinstance(value, str) or not re.fullmatch(r'[^\s/@]+/[^\s/@]+@[^\s@]+', value): |
| 37 | + raise ValueError(f'invalid dependency reference: {value!r}') |
| 38 | + repo, ref = value.split('@') |
| 39 | + return f'{repo.lower()}@{ref}' |
| 40 | + |
| 41 | + |
| 42 | +def load(path): |
| 43 | + """Load and validate a lock file as (dependencies, workflow roots). |
| 44 | +
|
| 45 | + Dependency keys and edges are normalized; each dependency value contains |
| 46 | + its ref, commit, and set of edges. Raise ValueError for invalid records, |
| 47 | + references, or missing targets. File, YAML, and key construction errors |
| 48 | + propagate to the caller. |
| 49 | + """ |
| 50 | + with open(path, encoding='utf-8') as f: |
| 51 | + doc = yaml.load(f, Loader=UniqueLoader) |
| 52 | + if not isinstance(doc, dict) or not isinstance(doc.get('workflows'), dict) or not isinstance(doc.get('dependencies'), dict): |
| 53 | + raise ValueError(f'{path}: expected workflows and dependencies mappings') |
| 54 | + deps = {} |
| 55 | + for name, record in doc['dependencies'].items(): |
| 56 | + k = key(name) |
| 57 | + if k in deps or not isinstance(record, dict): |
| 58 | + raise ValueError(f'{path}: duplicate or invalid record {name}') |
| 59 | + ref, commit = record.get('ref'), record.get('commit') |
| 60 | + if not isinstance(ref, str) or not re.fullmatch(r'[^\s@]+', ref) or ref.startswith('sha1-'): |
| 61 | + raise ValueError(f'{path}: invalid ref for {name}') |
| 62 | + if not isinstance(commit, str) or not re.fullmatch(r'sha1-[0-9a-f]{40}', commit): |
| 63 | + raise ValueError(f'{path}: invalid commit for {name}') |
| 64 | + named_ref = name.split('@')[1] |
| 65 | + if re.fullmatch(r'[0-9a-f]{40}', named_ref): |
| 66 | + if commit != 'sha1-' + named_ref: |
| 67 | + raise ValueError(f'{path}: SHA key and commit differ for {name}') |
| 68 | + elif named_ref != ref: |
| 69 | + raise ValueError(f'{path}: tag ref differs from key for {name}') |
| 70 | + edges = record.get('uses', []) |
| 71 | + if not isinstance(edges, list): |
| 72 | + raise ValueError(f'{path}: invalid uses for {name}') |
| 73 | + deps[k] = (ref, commit, {key(edge) for edge in edges}) |
| 74 | + roots = set() |
| 75 | + for workflow, edges in doc['workflows'].items(): |
| 76 | + if not isinstance(workflow, str) or not isinstance(edges, list): |
| 77 | + raise ValueError(f'{path}: invalid workflow {workflow!r}') |
| 78 | + roots.update(key(edge) for edge in edges) |
| 79 | + for edge in roots | set().union(*(r[2] for r in deps.values())): |
| 80 | + if edge not in deps: |
| 81 | + raise ValueError(f'{path}: missing dependency {edge}') |
| 82 | + return deps, roots |
| 83 | + |
| 84 | + |
| 85 | +def audit(old_path, new_path): |
| 86 | + """Compare reviewed dependencies reachable from proposed workflow roots. |
| 87 | +
|
| 88 | + For roots present in the reviewed lock, check that their reviewed |
| 89 | + reachable records, refs, commits, and edges remain in the proposed lock. |
| 90 | + Print a success summary; new sources still require independent review. |
| 91 | + Raise ValueError on a mismatch and propagate lock loading errors. |
| 92 | + """ |
| 93 | + old, _ = load(old_path) |
| 94 | + new, roots = load(new_path) |
| 95 | + queue = list(roots & old.keys()) |
| 96 | + seen = set() |
| 97 | + while queue: |
| 98 | + k = queue.pop() |
| 99 | + if k in seen: |
| 100 | + continue |
| 101 | + seen.add(k) |
| 102 | + if k not in new: |
| 103 | + raise ValueError(f'dropped reviewed dependency {k}') |
| 104 | + if old[k][:2] != new[k][:2]: |
| 105 | + raise ValueError(f'changed reviewed ref/commit {k}') |
| 106 | + missing = old[k][2] - new[k][2] |
| 107 | + if missing: |
| 108 | + raise ValueError(f'dropped reviewed edges from {k}: {sorted(missing)}') |
| 109 | + queue.extend(old[k][2]) |
| 110 | + print(f'Preserved reviewed reachable closure ({len(seen)} records); new sources require independent review.') |
| 111 | + |
| 112 | + |
| 113 | +if __name__ == '__main__': |
| 114 | + if len(sys.argv) != 3: |
| 115 | + sys.exit(f'usage: {sys.argv[0]} REVIEWED_LOCK PROPOSED_LOCK') |
| 116 | + try: |
| 117 | + audit(*sys.argv[1:]) |
| 118 | + except (OSError, ValueError, yaml.YAMLError) as exc: |
| 119 | + sys.exit(f'lock regeneration audit: {exc}') |
0 commit comments