master
py 587 lines 22.3 KB
Raw
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()