#!/usr/bin/env python3 import atexit import json import operator import os import subprocess import sys import urllib.request import zipfile from pathlib import Path # this requires being run on an OS with docker available to run an alpine container # through which apk can be invoked to compare versions natively. # # this generator will attempt to run an alpine container in the background # for the lifetime of the generator that will be used to exec apk; this is a lot faster # than running a dedicated container for each invocation, but does mean the container # may need to be cleaned up manually if the generator explodes in a way that prevents # it from stopping the container before exiting. # # this generator also uses cache to store the results of comparisons given the large # volume of packages and versions to compare, which is stored in the /tmp directory. # An array of version comparisons that are known to be unsupported and so # should be commented out in the generated fixture. # # Generally this is because the native implementation has a suspected bug # that causes the comparison to return incorrect results, and so supporting # such comparisons in the detector would in fact be wrong. UNSUPPORTED_COMPARISONS = [] def is_unsupported_comparison(line): return line in UNSUPPORTED_COMPARISONS def uncomment(line): if line.startswith('#'): return line[1:] if line.startswith('//'): return line[2:] return line def download_alpine_db(): urllib.request.urlretrieve('https://osv-vulnerabilities.storage.googleapis.com/Alpine/all.zip', 'alpine-db.zip') def extract_packages_with_versions(osvs): dict = {} for osv in osvs: for affected in osv['affected']: if 'package' not in affected or not affected['package']['ecosystem'].startswith('Alpine'): continue package = affected['package']['name'] if package not in dict: dict[package] = [] for version in affected.get('versions', []): dict[package].append(AlpineVersion(version)) # deduplicate and sort the versions for each package for package in dict: dict[package] = sorted(list(dict.fromkeys(dict[package]))) return dict class AlpineVersionComparer: def __init__(self, cache_path, how): self.cache_path = Path(cache_path) self.cache = {} self._alpine_version = '3.10' self._compare_method = how self._docker_container = None self._load_cache() def _start_docker_container(self): """ Starts the Alpine docker container for use in comparing versions using apk, assigning the name of the container to `self._docker_container` if success. If a container has already been started, this does nothing. """ if self._docker_container is not None: return container_name = f'alpine-{self._alpine_version}-container' cmd = ['docker', 'run', '--rm', '--name', container_name, '-d', f'alpine:{self._alpine_version}', 'tail', '-f', '/dev/null'] out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if out.returncode != 0: raise Exception(f'failed to start {container_name} container: {out.stderr.decode("utf-8")}') self._docker_container = container_name atexit.register(self._stop_docker_container) def _stop_docker_container(self): if self._docker_container is None: raise Exception(f'called to stop docker container when none was started') cmd = ['docker', 'stop', '-t', '0', self._docker_container] out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if out.returncode != 0: raise Exception(f'failed to stop {self._docker_container} container: {out.stderr.decode("utf-8")}') def _load_cache(self): if self.cache_path: self.cache_path.touch() with open(self.cache_path, 'r') as f: lines = f.readlines() for line in lines: line = line.strip() key, result = line.split(',') if result == 'True': self.cache[key] = True continue if result == 'False': self.cache[key] = False continue print(f"ignoring invalid cache entry '{line}'") def _save_to_cache(self, key, result): self.cache[key] = result if self.cache_path: self.cache_path.touch() with open(self.cache_path, 'a') as f: f.write(f'{key},{result}\n') def _compare_command(self, a, b): if self._compare_method == 'run': return ['docker', 'run', '--rm', f'alpine:{self._alpine_version}', 'apk', 'version', '-t', a, b] self._start_docker_container() return ['docker', 'exec', self._docker_container, 'apk', 'version', '-t', a, b] def compare(self, a, op, b): key = f'{a} {op} {b}' if key in self.cache: return self.cache[key] out = subprocess.run(self._compare_command(a, b), stdout=subprocess.PIPE, stderr=subprocess.PIPE) if out.returncode != 0: raise Exception(f'apk did not like comparing {a} {op} {b}: {out.stderr.decode("utf-8")}') r = out.stdout.decode('utf-8').strip() == op self._save_to_cache(key, r) return r alpine_comparer = AlpineVersionComparer('/tmp/alpine-versions-generator-cache.csv', 'exec') class AlpineVersion: def __str__(self): return self.version def __hash__(self): return hash(self.version) def __init__(self, version): self.version = version def __lt__(self, other): return alpine_comparer.compare(self.version, '<', other.version) def __gt__(self, other): return alpine_comparer.compare(self.version, '>', other.version) def __eq__(self, other): return alpine_comparer.compare(self.version, '=', other.version) def compare(v1, relate, v2): ops = {'<': operator.lt, '=': operator.eq, '>': operator.gt} return ops[relate](v1, v2) def compare_versions(lines, select='all'): has_any_failed = False for line in lines: line = line.strip() if line == '' or line.startswith('#') or line.startswith('//'): maybe_unsupported = uncomment(line).strip() if is_unsupported_comparison(maybe_unsupported): print(f'\033[96mS\033[0m: \033[93m{maybe_unsupported}\033[0m') continue v1, op, v2 = line.strip().split(' ') r = compare(AlpineVersion(v1), op, AlpineVersion(v2)) if not r: has_any_failed = r if select == 'failures' and r: continue if select == 'successes' and not r: continue color = '\033[92m' if r else '\033[91m' rs = 'T' if r else 'F' print(f'{color}{rs}\033[0m: \033[93m{line}\033[0m') return has_any_failed def compare_versions_in_file(filepath, select='all'): with open(filepath) as f: lines = f.readlines() return compare_versions(lines, select) def generate_version_compares(versions): comparisons = [] for i, version in enumerate(versions): if i == 0: continue comparison = f'{versions[i - 1]} < {version}\n' if is_unsupported_comparison(comparison.strip()): comparison = '# ' + comparison comparisons.append(comparison) return comparisons def generate_package_compares(packages): comparisons = [] for package in packages: versions = packages[package] comparisons.extend(generate_version_compares(versions)) # return comparisons return list(dict.fromkeys(comparisons)) def fetch_packages_versions(): download_alpine_db() osvs = [] with zipfile.ZipFile('alpine-db.zip') as db: for fname in db.namelist(): with db.open(fname) as osv: osvs.append(json.loads(osv.read().decode('utf-8'))) return extract_packages_with_versions(osvs) outfile = 'internal/semantic/testdata/alpine-versions-generated.txt' packs = fetch_packages_versions() with open(outfile, 'w') as f: f.writelines(generate_package_compares(packs)) f.write('\n') # set this to either "failures" or "successes" to only have those comparison results # printed; setting it to anything else will have all comparison results printed show = os.environ.get('VERSION_GENERATOR_PRINT', 'failures') did_any_fail = compare_versions_in_file(outfile, show) if did_any_fail: sys.exit(1)