| 1 | import click |
| 2 | import jsoncfg |
| 3 | from jsoncfg.config_classes import ConfigJSONObject, ConfigJSONArray, ConfigJSONScalar |
| 4 | import requests |
| 5 | import tempfile |
| 6 | import hashlib |
| 7 | import tarfile |
| 8 | import configparser |
| 9 | import magic |
| 10 | import os.path |
| 11 | import git |
| 12 | import re |
| 13 | import sys |
| 14 | from github import Github |
| 15 | |
| 16 | |
| 17 | USR_LIB_WSL = '/usr/lib/wsl' |
| 18 | USR_LIBEXEC_WSL = '/usr/libexec/wsl' |
| 19 | USR_SHARE_WSL = '/usr/share/wsl' |
| 20 | |
| 21 | MAGIC = magic.Magic() |
| 22 | X64_ELF_MAGIC = re.compile('^ELF 64-bit.* x86-64, version 1') |
| 23 | ARM64_ELF_MAGIC = re.compile('^ELF 64-bit.* ARM aarch64, version 1') |
| 24 | |
| 25 | KNOWN_TAR_FORMATS = {'^XZ compressed data.*': True, '^gzip compressed data.*': True} |
| 26 | |
| 27 | DISCOURAGED_SYSTEM_UNITS = ['systemd-resolved.service', |
| 28 | 'systemd-networkd.service', |
| 29 | 'systemd-networkd-wait-online.service', |
| 30 | 'systemd-tmpfiles-setup.service', |
| 31 | 'systemd-tmpfiles-clean.service', |
| 32 | 'systemd-tmpfiles-setup-dev-early.service', |
| 33 | 'systemd-tmpfiles-setup-dev.service', |
| 34 | 'systemd-vconsole-setup.service', |
| 35 | 'tmp.mount', |
| 36 | 'NetworkManager.service', |
| 37 | 'NetworkManager-wait-online.service', |
| 38 | 'console-getty.service', |
| 39 | 'networking.service', |
| 40 | 'hypervkvpd.service'] |
| 41 | |
| 42 | WSL1_UNSUPPORTED_XATTRS = ['security.selinux', 'security.ima', 'security.evm'] |
| 43 | |
| 44 | WSL_CONF_KEYS = ['automount.cgroups', |
| 45 | 'automount.enabled', |
| 46 | 'automount.ldconfig', |
| 47 | 'automount.mountfstab', |
| 48 | 'automount.options', |
| 49 | 'automount.root', |
| 50 | 'boot.command', |
| 51 | 'boot.protectbinfmt', |
| 52 | 'boot.systemd', |
| 53 | 'fileserver.enabled', |
| 54 | 'filesystem.umask', |
| 55 | 'general.hostname', |
| 56 | 'gpu.appendlibpath', |
| 57 | 'gpu.enabled', |
| 58 | 'interop.appendwindowspath', |
| 59 | 'interop.enabled', |
| 60 | 'network.generatehosts', |
| 61 | 'network.generateresolvconf', |
| 62 | 'network.hostname', |
| 63 | 'time.usewindowstimezone', |
| 64 | 'user.default'] |
| 65 | |
| 66 | errors = {} |
| 67 | warnings = {} |
| 68 | |
| 69 | def subset(inner, outer) -> bool: |
| 70 | for key, value in inner: |
| 71 | if key not in outer: |
| 72 | return True |
| 73 | |
| 74 | if not node_equals(value, outer[key]): |
| 75 | return False |
| 76 | |
| 77 | return True |
| 78 | |
| 79 | def node_equals(left, right): |
| 80 | if isinstance(left, ConfigJSONScalar): |
| 81 | return left() == right() |
| 82 | |
| 83 | elif isinstance(left, ConfigJSONArray): |
| 84 | return len(left) == len(right) and all(node_equals(l, r) for l, r in zip(left, right)) |
| 85 | else: |
| 86 | return subset(left, right) and subset(right, left) |
| 87 | |
| 88 | @click.command() |
| 89 | @click.option('--manifest', default=None) |
| 90 | @click.option('--tar', default=None) |
| 91 | @click.option('--compare-with-branch') |
| 92 | @click.option('--repo-path', '..') |
| 93 | @click.option('--arm64', is_flag=True) |
| 94 | @click.option('--debug', is_flag=True) |
| 95 | def main(manifest: str, tar: str, compare_with_branch: str, repo_path: str, arm64: bool, debug: bool): |
| 96 | try: |
| 97 | if tar is not None: |
| 98 | with open(tar, 'rb') as fd: |
| 99 | read_tar(None, fd, ARM64_ELF_MAGIC if arm64 else X64_ELF_MAGIC) |
| 100 | else: |
| 101 | if manifest is None: |
| 102 | raise RuntimeError('Either --tar or --manifest is required') |
| 103 | |
| 104 | manifest_content = jsoncfg.load_config(manifest) |
| 105 | |
| 106 | baseline_manifest = None |
| 107 | if compare_with_branch is not None: |
| 108 | repo = git.Repo(repo_path) |
| 109 | baseline_json = repo.commit(compare_with_branch).tree / 'distributions/DistributionInfo.json' |
| 110 | baseline_manifest = jsoncfg.loads_config(baseline_json.data_stream.read().decode())['ModernDistributions'] |
| 111 | |
| 112 | for flavor, versions in manifest_content["ModernDistributions"]: |
| 113 | baseline_flavor = baseline_manifest[flavor] if baseline_manifest and flavor in baseline_manifest else None |
| 114 | |
| 115 | for e in versions: |
| 116 | name = e['Name']() if 'Name' in e else None |
| 117 | |
| 118 | if name is None: |
| 119 | error(flavor, 'Found nameless distribution') |
| 120 | continue |
| 121 | |
| 122 | if baseline_flavor is not None: |
| 123 | baseline_version = next((entry for entry in baseline_flavor if entry['Name']() == name), None) |
| 124 | if baseline_version is None: |
| 125 | click.secho(f'Found new entry for flavor "{flavor}": {name}', fg='green', bold=True) |
| 126 | elif not node_equals(baseline_version, e): |
| 127 | click.secho(f'Found changed entry for flavor "{flavor}": {name}', fg='green', bold=True) |
| 128 | else: |
| 129 | click.secho(f'Distribution entry "{flavor}/{name}" is unchanged, skipping') |
| 130 | continue |
| 131 | |
| 132 | click.secho(f'Reading information for distribution: {name}', bold=True) |
| 133 | if 'FriendlyName' not in e: |
| 134 | error(e, 'Manifest entry is missing a "FriendlyName" entry') |
| 135 | |
| 136 | if not name.startswith(flavor): |
| 137 | error(e, f'Name should start with "{flavor}"') |
| 138 | |
| 139 | url_found = False |
| 140 | |
| 141 | if 'Amd64Url' in e: |
| 142 | read_url(e['Amd64Url'], X64_ELF_MAGIC) |
| 143 | url_found = True |
| 144 | |
| 145 | if 'Arm64Url' in e: |
| 146 | read_url(e['Arm64Url'], ARM64_ELF_MAGIC) |
| 147 | url_found = True |
| 148 | |
| 149 | if not url_found: |
| 150 | error(flavor, 'No URL found') |
| 151 | |
| 152 | expectedKeys = ['Name', 'FriendlyName', 'Default', 'Amd64Url', 'Arm64Url'] |
| 153 | for key, value in e: |
| 154 | if key not in expectedKeys: |
| 155 | error(e, f'Unexpected key: "{key}"') |
| 156 | |
| 157 | default_entries = sum(1 for e in versions if 'Default' in e and e['Default']()) |
| 158 | if default_entries != 1: |
| 159 | error(e, f'Found no default distribution for "{flavor}"' if default_entries == 0 else f'Found multiple default distributions for "{flavor}"') |
| 160 | |
| 161 | report_status_on_pr(manifest) |
| 162 | |
| 163 | sys.exit(1 if errors else 0) |
| 164 | |
| 165 | except: |
| 166 | if debug: |
| 167 | import traceback |
| 168 | traceback.print_exc() |
| 169 | import pdb |
| 170 | pdb.post_mortem() |
| 171 | else: |
| 172 | raise |
| 173 | |
| 174 | def report_status_on_pr(manifest: str): |
| 175 | def format_list(entries: list) -> str: |
| 176 | if len(entries) == 1: |
| 177 | return entries[0] |
| 178 | |
| 179 | output = '' |
| 180 | for e in entries: |
| 181 | output += f'\n* {e}' |
| 182 | |
| 183 | return output |
| 184 | |
| 185 | for line, text in errors.items(): |
| 186 | escaped = format_list(text).replace('\n', '%0A') |
| 187 | print(f'::error file={manifest},line={line}::Error: {escaped}') |
| 188 | |
| 189 | for line, text in warnings.items(): |
| 190 | escaped = format_list(text).replace('\n', '%0A') |
| 191 | print(f'::warning file={manifest},line={line}::Warning: {escaped}') |
| 192 | |
| 193 | |
| 194 | def read_config_keys(config: configparser.ConfigParser) -> dict: |
| 195 | keys = {} |
| 196 | |
| 197 | for section in config.sections(): |
| 198 | for key in config[section].keys(): |
| 199 | keys[f'{section}.{key.lower()}'] = config[section][key] |
| 200 | |
| 201 | return keys |
| 202 | |
| 203 | def read_passwd(node, default_uid: int, fd): |
| 204 | def read_passwd_line(line: str): |
| 205 | fields = line.split(':') |
| 206 | |
| 207 | if len(fields) != 7: |
| 208 | error(node, f'Invalid passwd entry: {line}') |
| 209 | return None, None |
| 210 | try: |
| 211 | uid = int(fields[2]) |
| 212 | except ValueError: |
| 213 | error(node, f'Invalid passwd entry: {line}') |
| 214 | return None, None |
| 215 | |
| 216 | return uid, fields |
| 217 | |
| 218 | entries = {} |
| 219 | |
| 220 | for line in fd.readlines(): |
| 221 | uid, fields = read_passwd_line(line.decode()) |
| 222 | |
| 223 | if uid in entries: |
| 224 | error(node, f'found duplicated uid in /etc/passw: {uid}') |
| 225 | else: |
| 226 | entries[uid] = fields |
| 227 | |
| 228 | if 0 not in entries: |
| 229 | error(node, f'No root (uid=0) found in /etc/passwd') |
| 230 | elif entries[0][0] != 'root': |
| 231 | error(node, f'/etc/passwd has a uid=0, but it is not root: {entries[0][0]}') |
| 232 | |
| 233 | if default_uid is not None and default_uid in entries: |
| 234 | warning(node, f'/etc/passwd already has an entry for default uid: {entries[default_uid]}') |
| 235 | |
| 236 | # This logic isn't perfect at listing all boot units, but parsing all of systemd configuration would be too complex. |
| 237 | def read_systemd_enabled_units(node, tar) -> dict: |
| 238 | config_dirs = ['/usr/local/lib/systemd/system', '/usr/lib/systemd/system', '/etc/systemd/system'] |
| 239 | |
| 240 | all_files = tar.getnames() |
| 241 | |
| 242 | def link_target(unit_path: str): |
| 243 | info = get_tar_file(tar, unit_path, follow_symlink=False)[0] |
| 244 | if info is None: |
| 245 | raise KeyError(unit_path) |
| 246 | |
| 247 | if not info.issym(): |
| 248 | return unit_path |
| 249 | |
| 250 | if info.linkpath.startswith('/'): |
| 251 | resolved = linux_real_path(info.linkpath) |
| 252 | else: |
| 253 | resolved = linux_real_path(os.path.dirname(unit_path) + '/' + info.linkpath) |
| 254 | |
| 255 | real = get_tar_file(tar, resolved, follow_symlink=True)[1] |
| 256 | if real is not None: |
| 257 | return real |
| 258 | |
| 259 | return resolved if resolved.startswith('/') else '/' + resolved |
| 260 | |
| 261 | def list_directory(path: str): |
| 262 | prefix = path.strip('/') |
| 263 | files = [] |
| 264 | for e in all_files: |
| 265 | normalized = e |
| 266 | if normalized.startswith('./'): |
| 267 | normalized = normalized[2:] |
| 268 | elif normalized.startswith('/'): |
| 269 | normalized = normalized[1:] |
| 270 | normalized = normalized.rstrip('/') |
| 271 | |
| 272 | if normalized == prefix: |
| 273 | continue # The directory itself, not an entry within it |
| 274 | |
| 275 | if normalized.startswith(prefix + '/'): |
| 276 | files.append(normalized[len(prefix) + 1:]) |
| 277 | |
| 278 | return files |
| 279 | |
| 280 | def is_dev_null(path: str) -> bool: |
| 281 | return path == './dev/null' or path == '/dev/null' |
| 282 | |
| 283 | def is_masked(unit: str): |
| 284 | try: |
| 285 | target = link_target(f'/etc/systemd/system/{unit}') |
| 286 | except KeyError: |
| 287 | return False # No symlink found, unit is not masked |
| 288 | |
| 289 | return is_dev_null(target) |
| 290 | |
| 291 | units = {} |
| 292 | for config_dir in config_dirs: |
| 293 | targets = [e for e in list_directory(config_dir) if e.endswith('.target.wants')] |
| 294 | |
| 295 | for target in targets: |
| 296 | for e in list_directory(f'{config_dir}/{target}'): |
| 297 | fullpath = f'{config_dir}/{target}/{e}' |
| 298 | |
| 299 | unit_target = link_target(fullpath) |
| 300 | |
| 301 | if not is_dev_null(unit_target) and not is_masked(e): |
| 302 | units[e] = fullpath |
| 303 | |
| 304 | return units |
| 305 | |
| 306 | # Manually implemented because os.path.realpath tries to resolve local symlinks |
| 307 | def linux_real_path(path: str): |
| 308 | components = path.split('/') |
| 309 | |
| 310 | result = [] |
| 311 | for e in components: |
| 312 | if e == '.' or not e: |
| 313 | continue |
| 314 | elif e == '..': |
| 315 | if result: |
| 316 | del result[-1] |
| 317 | continue |
| 318 | |
| 319 | result.append(e) |
| 320 | |
| 321 | real_path = '/'.join(result) |
| 322 | if path and path[0] == '/': |
| 323 | return '/' + real_path |
| 324 | else: |
| 325 | return real_path |
| 326 | |
| 327 | def get_tar_file(tar, path: str, follow_symlink=False, symlink_depth=10): |
| 328 | if symlink_depth < 0: |
| 329 | print(f'Warning: Exceeded maximum symlink depth when reading: {path}') |
| 330 | return None, None |
| 331 | |
| 332 | # Tar members can be formatted as /{path}, {path}, or ./{path} |
| 333 | if path.startswith('/'): |
| 334 | paths = [path, '.' + path, path[1:]] |
| 335 | elif path.startswith('./'): |
| 336 | paths = [path, path[1:], path[2:]] |
| 337 | else: |
| 338 | paths = [path, './' + path, '/' + path] |
| 339 | |
| 340 | def follow_if_symlink(info, path: str): |
| 341 | if follow_symlink and info.issym(): |
| 342 | if info.linkpath.startswith('/'): |
| 343 | return get_tar_file(tar, info.linkpath, follow_symlink=True, symlink_depth=symlink_depth - 1) |
| 344 | else: |
| 345 | return get_tar_file(tar, linux_real_path(os.path.dirname(path) + '/' + info.linkpath), follow_symlink=True, symlink_depth=symlink_depth -1) |
| 346 | else: |
| 347 | return info, path |
| 348 | |
| 349 | # First try accessing the file directly |
| 350 | for e in paths: |
| 351 | try: |
| 352 | return follow_if_symlink(tar.getmember(e), e) |
| 353 | except KeyError: |
| 354 | continue |
| 355 | |
| 356 | if not follow_symlink: |
| 357 | return None, None |
| 358 | |
| 359 | # Then look for symlinks |
| 360 | # The path might be covered by a symlink, check if parent exists and is a symlink |
| 361 | parent_path = os.path.dirname(path) |
| 362 | if parent_path != path: |
| 363 | try: |
| 364 | parent_info, real_parent_path = get_tar_file(tar, parent_path, follow_symlink=True, symlink_depth=symlink_depth - 1) |
| 365 | if real_parent_path is not None and real_parent_path != parent_path: |
| 366 | return get_tar_file(tar, f'{real_parent_path}/{os.path.basename(path)}', follow_symlink=True, symlink_depth=symlink_depth -1) |
| 367 | except KeyError: |
| 368 | pass |
| 369 | |
| 370 | return None, None |
| 371 | |
| 372 | def find_unsupported_attrs(tar): |
| 373 | found_xattrs = set() |
| 374 | first_file = None |
| 375 | |
| 376 | for e in tar.getmembers(): |
| 377 | for name in e.pax_headers: |
| 378 | if any(name.startswith('SCHILY.xattr.' + xattr) for xattr in WSL1_UNSUPPORTED_XATTRS): |
| 379 | found_xattrs.add(name.replace('SCHILY.xattr.', '')) |
| 380 | |
| 381 | if first_file is None: |
| 382 | first_file = e.name |
| 383 | |
| 384 | return first_file, found_xattrs |
| 385 | |
| 386 | |
| 387 | def read_tar(node, file, elf_magic: str): |
| 388 | with tarfile.open(fileobj=file) as tar: |
| 389 | |
| 390 | def validate_mode(path: str, mode, uid, gid, max_size = None, optional = False, follow_symlink = False, magic = None, parse_method = None): |
| 391 | info, real_path = get_tar_file(tar, path, follow_symlink) |
| 392 | |
| 393 | if info is None: |
| 394 | if not optional: |
| 395 | error(node, f'File "{path}" not found in tar') |
| 396 | return False |
| 397 | |
| 398 | permissions = oct(info.mode) |
| 399 | if permissions not in mode: |
| 400 | warning(node, f'file: "{path}" has unexpected mode: {permissions} (expected: {mode})') |
| 401 | |
| 402 | if info.uid != uid: |
| 403 | warning(node, f'file: "{path}" has unexpected uid: {info.uid} (expected: {uid})') |
| 404 | |
| 405 | if gid is not None and info.gid != gid: |
| 406 | warning(node, f'file: "{path}" has unexpected gid: {info.gid} (expected: {gid})') |
| 407 | |
| 408 | if max_size is not None and info.size > max_size: |
| 409 | error(node, f'file: "{path}" is too big ({info.size}), max: {max_size}') |
| 410 | |
| 411 | if magic is not None or parse_method is not None: |
| 412 | content = tar.extractfile(real_path) |
| 413 | |
| 414 | if parse_method is not None: |
| 415 | parse_method(content) |
| 416 | |
| 417 | if magic is not None: |
| 418 | content.seek(0) |
| 419 | buffer = content.read(256) |
| 420 | file_magic = MAGIC.from_buffer(buffer) |
| 421 | if not magic.match(file_magic): |
| 422 | error(node, f'file: "{path}" has unexpected magic type: {file_magic} (expected: {magic})') |
| 423 | |
| 424 | return True |
| 425 | |
| 426 | def validate_config(path: str, valid_keys: list): |
| 427 | _, real_path = get_tar_file(tar, path, follow_symlink=True) |
| 428 | if real_path is None: |
| 429 | error(node, f'File "{path}" not found in tar') |
| 430 | return None |
| 431 | |
| 432 | content = tar.extractfile(real_path) |
| 433 | config = configparser.ConfigParser() |
| 434 | config.read_string(content.read().decode()) |
| 435 | |
| 436 | keys = read_config_keys(config) |
| 437 | |
| 438 | unexpected_keys = [e for e in keys if e.casefold() not in valid_keys] |
| 439 | if unexpected_keys: |
| 440 | error(node, f'Found unexpected_keys in "{path}": {unexpected_keys}') |
| 441 | else: |
| 442 | click.secho(f'Found valid keys in "{path}": {list(keys.keys())}') |
| 443 | |
| 444 | return keys |
| 445 | |
| 446 | defaultUid = None |
| 447 | if validate_mode('/etc/wsl-distribution.conf', [oct(0o664), oct(0o644)], 0, 0, follow_symlink=True): |
| 448 | config = validate_config('/etc/wsl-distribution.conf', ['oobe.command', 'oobe.defaultuid', 'shortcut.icon', 'shortcut.enabled', 'oobe.defaultname', 'windowsterminal.profiletemplate', 'windowsterminal.enabled']) |
| 449 | |
| 450 | if oobe_command := config.get('oobe.command', None): |
| 451 | validate_mode(oobe_command, [oct(0o775), oct(0o755)], 0, 0) |
| 452 | |
| 453 | if not oobe_command.startswith(USR_LIB_WSL) and not oobe_command.startswith(USR_LIBEXEC_WSL): |
| 454 | warning(node, f'value for oobe.command is not under {USR_LIB_WSL} or {USR_LIBEXEC_WSL}: "{oobe_command}"') |
| 455 | |
| 456 | if defaultUid := config.get('oobe.defaultuid', None): |
| 457 | if defaultUid != '1000': |
| 458 | warning(node, f'Default UID is not 1000. Found: {defaultUid}') |
| 459 | |
| 460 | defaultUid = int(defaultUid) |
| 461 | |
| 462 | if shortcut_icon := config.get('shortcut.icon', None): |
| 463 | validate_mode(shortcut_icon, [oct(0o664), oct(0o644)], 0, 0, 1024 * 1024) |
| 464 | |
| 465 | if not shortcut_icon.startswith(USR_LIB_WSL) and not shortcut_icon.startswith(USR_SHARE_WSL): |
| 466 | warning(node, f'value for shortcut.icon is not under {USR_LIB_WSL} or {USR_SHARE_WSL}: "{shortcut_icon}"') |
| 467 | else: |
| 468 | warning(node, 'No shortcut.icon provided') |
| 469 | |
| 470 | if terminal_profile := config.get('windowsterminal.profiletemplate', None): |
| 471 | validate_mode(terminal_profile, [oct(0o660), oct(0o640)], 0, 0, 1024 * 1024) |
| 472 | |
| 473 | if not terminal_profile.startswith(USR_LIB_WSL): |
| 474 | warning(node, f'value for windowsterminal.profileTemplate is not under {USR_LIB_WSL}: "{terminal_profile}"') |
| 475 | |
| 476 | if validate_mode('/etc/wsl.conf', [oct(0o664), oct(0o644)], 0, 0, optional=True, follow_symlink=True): |
| 477 | config = validate_config('/etc/wsl.conf', WSL_CONF_KEYS) |
| 478 | if config.get('boot.systemd', False): |
| 479 | validate_mode('/sbin/init', [oct(0o775), oct(0o755), oct(0o555)], 0, 0, magic=elf_magic, follow_symlink=True) |
| 480 | |
| 481 | if (default_user := config.get('user.default')) is not None: |
| 482 | warning(node, f'Found discouraged wsl.conf key: user.default={default_user}') |
| 483 | |
| 484 | validate_mode('/etc/passwd', [oct(0o664), oct(0o644)], 0, 0, parse_method = lambda fd: read_passwd(node, defaultUid, fd)) |
| 485 | validate_mode('/etc/shadow', [oct(0o640), oct(0o600), oct(0)], 0, None) |
| 486 | validate_mode('/bin/bash', [oct(0o755), oct(0o775), oct(0o555)], 0, 0, magic=elf_magic, follow_symlink=True, optional=True) |
| 487 | validate_mode('/bin/sh', [oct(0o755), oct(0o775), oct(0o555)], 0, 0, magic=elf_magic, follow_symlink=True) |
| 488 | |
| 489 | enabled_systemd_units = read_systemd_enabled_units(node, tar) |
| 490 | for unit, path in enabled_systemd_units.items(): |
| 491 | if unit in DISCOURAGED_SYSTEM_UNITS: |
| 492 | warning(node, f'Found discouraged system unit: {path}') |
| 493 | |
| 494 | first_file, found_xattrs = find_unsupported_attrs(tar) |
| 495 | if first_file is not None: |
| 496 | warning(node, f'Found extended attributes that are not supported in WSL1: {found_xattrs}. Sample file: {first_file}') |
| 497 | |
| 498 | def read_url(url: dict, elf_magic): |
| 499 | hash = hashlib.sha256() |
| 500 | address = url['Url']() |
| 501 | |
| 502 | if not address.endswith('.wsl'): |
| 503 | warning(url, f'Url does not point to a .wsl file: {address}') |
| 504 | |
| 505 | tar_format = None |
| 506 | if address.startswith('file://'): |
| 507 | with open(address.replace('file:///', '').replace('file://', ''), 'rb') as fd: |
| 508 | while True: |
| 509 | e = fd.read(4096 * 4096 * 10) |
| 510 | if not e: |
| 511 | break |
| 512 | |
| 513 | hash.update(e) |
| 514 | |
| 515 | if tar_format is None: |
| 516 | tar_format = MAGIC.from_buffer(e) |
| 517 | |
| 518 | fd.seek(0, 0) |
| 519 | read_tar(url, fd, elf_magic) |
| 520 | else: |
| 521 | with requests.get(address, stream=True) as response: |
| 522 | |
| 523 | try: |
| 524 | response.raise_for_status() |
| 525 | except Exception as e: |
| 526 | error(url, str(e)) |
| 527 | return |
| 528 | |
| 529 | with tempfile.NamedTemporaryFile() as file: |
| 530 | for e in response.iter_content(chunk_size=4096 * 4096): |
| 531 | file.write(e) |
| 532 | hash.update(e) |
| 533 | |
| 534 | if tar_format is None: |
| 535 | tar_format = MAGIC.from_buffer(e) |
| 536 | |
| 537 | file.seek(0, 0) |
| 538 | |
| 539 | try: |
| 540 | read_tar(url, file, elf_magic) |
| 541 | except Exception as e: |
| 542 | error(url, f"Failed to read tar from URL: {address}: {e}") |
| 543 | |
| 544 | |
| 545 | expected_sha = url['Sha256']() if 'Sha256' in url else None |
| 546 | if expected_sha is None: |
| 547 | error(url, 'URL is missing "Sha256"') |
| 548 | else: |
| 549 | if expected_sha.startswith('0x'): |
| 550 | expected_sha = expected_sha[2:] |
| 551 | |
| 552 | sha = hash.digest() |
| 553 | if bytes.fromhex(expected_sha) != sha: |
| 554 | error(url, f'URL {address} Sha256 does not match. Expected: {expected_sha}, actual: {hash.hexdigest()}') |
| 555 | else: |
| 556 | click.secho(f'Hash for {address} matches ({expected_sha})', fg='green') |
| 557 | |
| 558 | known_format = next((value for key, value in KNOWN_TAR_FORMATS.items() if re.match(key, tar_format)), None) |
| 559 | if known_format is None: |
| 560 | error(url, f'Unknown tar format: {tar_format}') |
| 561 | elif not known_format: |
| 562 | warning(url, f'Tar format not supported by WSL1: {tar_format}') |
| 563 | |
| 564 | def error(node, message: str): |
| 565 | if node is None: |
| 566 | click.secho(f'Error: {message}', fg='red') |
| 567 | else: |
| 568 | global errors |
| 569 | |
| 570 | line = jsoncfg.node_location(node).line |
| 571 | click.secho(f'Error on line {line}: {message}', fg='red') |
| 572 | |
| 573 | errors[line] = errors.get(line, []) + [message] |
| 574 | |
| 575 | def warning(node, message: str): |
| 576 | if node is None: |
| 577 | click.secho(f'Warning: {message}', fg='yellow') |
| 578 | else: |
| 579 | global warnings |
| 580 | |
| 581 | line = jsoncfg.node_location(node).line |
| 582 | click.secho(f'Warning on line {line}: {message}', fg='yellow') |
| 583 | |
| 584 | warnings[line] = warnings.get(line, []) + [message] |
| 585 | |
| 586 | if __name__ == "__main__": |
| 587 | main() |