1#!@TOOLS_PYTHON@ -Es 2# 3# This file and its contents are supplied under the terms of the 4# Common Development and Distribution License ("CDDL"), version 1.0. 5# You may only use this file in accordance with the terms of version 6# 1.0 of the CDDL. 7# 8# A full copy of the text of the CDDL should have accompanied this 9# source. A copy of the CDDL is also available via the Internet at 10# http://www.illumos.org/license/CDDL. 11# 12 13# 14# Copyright 2026 Gordon W. Ross 15# 16 17# 18# Check test runfiles against installed test artifacts in proto. 19# 20# For each section in a runfile, verify that the test group path, 21# the tests (if listed), and any pre/post auxiliary scripts exist 22# in the proto area and are executable. Auxiliary scripts are 23# allowed to be outside the test group's own directory. 24# 25 26import argparse 27import ast 28import configparser 29import io 30import os 31import posixpath 32import re 33import sys 34import tokenize 35 36SECTION_RE = re.compile(r'^\s*\[(.+?)\]\s*$') 37 38 39def parse_args(): 40 parser = argparse.ArgumentParser( 41 description='Validate test runfiles against proto/root_i386 installs') 42 parser.add_argument( 43 '-R', dest='root', default=os.environ.get('ROOT'), 44 help='proto root path (for example, .../proto/root_i386); ' 45 'default: $ROOT') 46 parser.add_argument( 47 '-T', dest='testroot', required=True, 48 help='test root relative to proto root (for example, opt/os-tests)') 49 parser.add_argument( 50 '-a', '--arch', default='i86pc', 51 help='only check sections where arch matches this value ' 52 '(default: i86pc)') 53 parser.add_argument( 54 'runfiles', nargs='+', 55 help='runfile paths to check') 56 57 args = parser.parse_args() 58 if args.root is None: 59 parser.error('missing proto root: set -R <root> or the ROOT ' 60 'environment variable') 61 return args 62 63 64def find_runfiles(args): 65 return list(args.runfiles) 66 67 68def normalize_testroot(testroot): 69 root = testroot.strip() 70 if not root: 71 return None 72 if root.startswith('/'): 73 return None 74 root = posixpath.normpath(root) 75 if root in ('.', '..') or root.startswith('../'): 76 return None 77 return root 78 79 80def section_path(section, testroot): 81 raw = section.split(':', 1)[0].strip() 82 if raw.startswith('/'): 83 return posixpath.normpath(raw) 84 return posixpath.normpath(posixpath.join('/', testroot, raw)) 85 86 87def section_raw_path(section): 88 return section.split(':', 1)[0].strip() 89 90 91def is_test_file(path): 92 return (os.path.isfile(path) and not os.path.islink(path) and 93 os.access(path, os.X_OK)) 94 95 96def has_adjacent_string_literals(expr): 97 try: 98 toks = tokenize.generate_tokens(io.StringIO(expr).readline) 99 except tokenize.TokenError: 100 return False 101 102 prev = None 103 ignored = set([tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, 104 tokenize.DEDENT, tokenize.COMMENT, tokenize.ENDMARKER]) 105 for tok in toks: 106 if tok.type in ignored: 107 continue 108 if tok.type == tokenize.STRING and prev == tokenize.STRING: 109 return True 110 prev = tok.type 111 return False 112 113 114def section_line_map(content): 115 lines = {} 116 for lineno, line in enumerate(content.splitlines(), 1): 117 match = SECTION_RE.match(line) 118 if match is None: 119 continue 120 section = match.group(1).strip() 121 if section not in lines: 122 lines[section] = lineno 123 return lines 124 125 126def format_error(runfile, lineno, test_group, detail): 127 loc = '%s:%s' % (runfile, lineno) 128 return 'In test group %s, %s: %s' % (test_group, loc, detail) 129 130 131def emit_error(issues, runfile, lineno, test_group, detail): 132 issues.append(format_error(runfile, lineno, test_group, detail)) 133 134 135def check_runfile(runfile, testroot, protoroot, arch): 136 issues = [] 137 config = configparser.RawConfigParser() 138 content = None 139 140 try: 141 with open(runfile, encoding='utf-8') as f: 142 content = f.read() 143 except OSError as err: 144 return ['%s: failed to read runfile: %s' % (runfile, err)] 145 146 section_lines = section_line_map(content) 147 148 try: 149 config.read_string(content, source=runfile) 150 except configparser.Error as err: 151 return ['%s: parse error: %s' % (os.path.basename(runfile), err)] 152 153 for sec in config.sections(): 154 lineno = section_lines.get(sec, '?') 155 if section_raw_path(sec).upper() == 'DEFAULT': 156 continue 157 if config.has_option(sec, 'arch'): 158 sec_arch = config.get(sec, 'arch').strip() 159 if sec_arch != arch: 160 continue 161 162 secpath = section_path(sec, testroot) 163 proto_path = os.path.join(protoroot, secpath.lstrip('/')) 164 has_tests = config.has_option(sec, 'tests') 165 has_autotests = config.has_option(sec, 'autotests') 166 167 if has_tests or has_autotests: 168 path_exists = os.path.exists(proto_path) 169 path_is_dir = os.path.isdir(proto_path) 170 path_is_file = os.path.isfile(proto_path) 171 172 if not path_exists: 173 emit_error(issues, runfile, lineno, secpath, 174 'test group path not found in proto area.') 175 elif path_is_file: 176 if has_tests: 177 emit_error( 178 issues, runfile, lineno, secpath, 179 'test group path is a file and should not have a ' 180 'tests property.') 181 else: 182 emit_error( 183 issues, runfile, lineno, secpath, 184 'test group path is a file and should not have an ' 185 'autotests property.') 186 187 if has_tests: 188 tests_raw = config.get(sec, 'tests') 189 if has_adjacent_string_literals(tests_raw): 190 emit_error( 191 issues, runfile, lineno, secpath, 192 'tests list contains adjacent string literals ' 193 '(possible missing comma).') 194 195 try: 196 tests = ast.literal_eval(tests_raw) 197 except (SyntaxError, ValueError) as err: 198 emit_error( 199 issues, runfile, lineno, secpath, 200 'tests is not a valid Python list: %s' % err) 201 continue 202 203 if not isinstance(tests, list): 204 emit_error(issues, runfile, lineno, secpath, 205 'tests must evaluate to a list.') 206 continue 207 208 bad = [repr(x) for x in tests if not isinstance(x, str)] 209 if bad: 210 emit_error(issues, runfile, lineno, secpath, 211 'tests must contain only strings: %s' % 212 ', '.join(bad)) 213 continue 214 215 if not path_is_dir: 216 continue 217 218 for test in tests: 219 tpath = os.path.join(proto_path, test) 220 if not is_test_file(tpath): 221 emit_error(issues, runfile, lineno, secpath, 222 'test %s not found in proto area.' % test) 223 else: 224 if not is_test_file(proto_path): 225 emit_error(issues, runfile, lineno, secpath, 226 'single test path not found in proto area.') 227 228 # Check pre/post auxiliary scripts. They are allowed to live in any 229 # directory, so we only verify they exist in the proto area. 230 aux_dir = secpath if (has_tests or has_autotests) \ 231 else posixpath.dirname(secpath) 232 for prop in ('pre', 'post'): 233 if not config.has_option(sec, prop): 234 continue 235 val = config.get(sec, prop).strip() 236 if not val: 237 continue 238 if posixpath.isabs(val): 239 apath = os.path.join(protoroot, val.lstrip('/')) 240 else: 241 apath = os.path.join(protoroot, aux_dir.lstrip('/'), val) 242 if not is_test_file(apath): 243 emit_error(issues, runfile, lineno, secpath, 244 '%s script not found in proto area: %s' % 245 (prop, val)) 246 247 return issues 248 249 250def main(): 251 args = parse_args() 252 protoroot = os.path.abspath(os.path.expanduser(args.root)) 253 testroot = normalize_testroot(args.testroot) 254 255 if not os.path.isdir(protoroot): 256 sys.stderr.write('error: proto root not found: %s\n' % protoroot) 257 return 2 258 259 if not os.path.isdir(os.path.join(protoroot, 'opt')): 260 sys.stderr.write('error: invalid proto root (missing opt/): %s\n' % 261 protoroot) 262 return 2 263 264 if testroot is None: 265 sys.stderr.write('error: invalid -T value (must be a relative path ' 266 'under proto root): %s\n' % args.testroot) 267 return 2 268 269 testroot_path = os.path.join(protoroot, testroot) 270 if not os.path.isdir(testroot_path): 271 sys.stderr.write('error: test root not found under proto root: %s\n' % 272 testroot_path) 273 return 2 274 275 runfiles = find_runfiles(args) 276 issues = [] 277 for runfile in runfiles: 278 issues.extend(check_runfile(runfile, testroot, protoroot, args.arch)) 279 280 for issue in issues: 281 sys.stderr.write('%s\n' % issue) 282 283 return 1 if issues else 0 284 285 286if __name__ == '__main__': 287 sys.exit(main()) 288