summaryrefslogtreecommitdiff
path: root/Meta/lint-ports.py
blob: 23e37effdfb3bad00e84eae99e807ea9c1da87e1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
#!/usr/bin/env python3

import os
import re
import sys
import subprocess
from pathlib import Path
from tempfile import NamedTemporaryFile

# Matches e.g. "| [`bash`](bash/) | GNU Bash | 5.0 | https://www.gnu.org/software/bash/ |"
# and captures "bash" in group 1, "bash/" in group 2, "<spaces>" in group 3, "GNU Bash" in group 4, "5.0" in group 5
# and "https://www.gnu.org/software/bash/" in group 6.
PORT_TABLE_REGEX = re.compile(
    r'^\| \[`([^`]+)`\]\(([^\)]+)\)([^\|]+) \| ([^\|]+) \| ([^\|]+?) \| ([^\|]+) \|+$', re.MULTILINE
)

# Matches non-abbreviated git hashes
GIT_HASH_REGEX = re.compile(r'^[0-9a-f]{40}$')

PORT_TABLE_FILE = 'AvailablePorts.md'
IGNORE_FILES = {
    '.gitignore',
    '.port_include.sh',
    PORT_TABLE_FILE,
    'build_all.sh',
    'build_installed.sh',
    'README.md',
    '.hosted_defs.sh'
}

# Matches port names in Ports/foo/ReadMe.md
PORT_NAME_REGEX = re.compile(r'([ .()[\]{}\w-]+)\.patch')
PORTS_MISSING_DESCRIPTIONS = {
    'Another-World',
    'chester',
    'cmatrix',
    'c-ray',
    'curl',
    'dash',
    'diffutils',
    'dosbox-staging',
    'dropbear',
    'ed',
    'emu2',
    'epsilon',
    'figlet',
    'flex',
    'fontconfig',
    'freeciv',
    'freedink',
    'freetype',
    'gawk',
    'gcc',
    'genemu',
    'gettext',
    'git',
    'gltron',
    'gmp',
    'gnucobol',
    'gnupg',
    'gnuplot',
    'gsl',
    'harfbuzz',
    'indent',
    'jq',
    'klong',
    'libassuan',
    'libgcrypt',
    'libgd',
    'libgpg-error',
    'libiconv',
    'libicu',
    'libjpeg',
    'libksba',
    'libmodplug',
    'liboggz',
    'libpng',
    'libpuffy',
    'libsodium',
    'libvorbis',
    'libzip',
    'lua',
    'm4',
    'make',
    'mandoc',
    'mbedtls',
    'milkytracker',
    'mrsh',
    'mruby',
    'nano',
    'ncurses',
    'neofetch',
    'nethack',
    'ninja',
    'npiet',
    'npth',
    'ntbtls',
    'nyancat',
    'oksh',
    'openssh',
    'openssl',
    'openttd',
    'opentyrian',
    'p7zip',
    'patch',
    'pcre2',
    'pfetch',
    'pkgconf',
    'pt2-clone',
    'qt6-qtbase',
    'ruby',
    'sam',
    'scummvm',
    'SDL2_image',
    'SDL2_mixer',
    'SDL2_net',
    'SDL2_ttf',
    'sl',
    'sqlite',
    'tcl',
    'tinycc',
    'tr',
    'tuxracer',
    'vitetris',
    'wget',
    'xz',
    'zsh',
    'zstd',
}

# FIXME: Once everything is converted into `git format-patch`-style patches,
#        enable this to allow only `git format-patch` patches.
REQUIRE_GIT_PATCHES = False
GIT_PATCH_SUBJECT_RE = re.compile(r'Subject: (.*)\n')


def read_port_table(filename):
    """Open a file and find all PORT_TABLE_REGEX matches.

    Args:
        filename (str): filename

    Returns:
        set: all PORT_TABLE_REGEX matches
    """
    ports = {}
    with open(filename, 'r') as fp:
        matches = PORT_TABLE_REGEX.findall(fp.read())
        for match in matches:
            line_len = sum([len(part) for part in match])
            ports[match[0]] = {
                "dir_ref": match[1],
                "name": match[2].strip(),
                "version": match[4].strip(),
                "url": match[5].strip(),
                "line_len": line_len
            }
    return ports


def read_port_dirs():
    """Check Ports directory for unexpected files and check each port has a package.sh file.

    Returns:
        list: all ports (set), no errors encountered (bool)
    """

    ports = {}
    all_good = True
    for entry in os.listdir():
        if entry in IGNORE_FILES:
            continue
        if not os.path.isdir(entry):
            print(f"Ports/{entry} is neither a port (not a directory) nor an ignored file?!")
            all_good = False
            continue
        if not os.path.exists(entry + '/package.sh'):
            print(f"Ports/{entry}/ is missing its package.sh?!")
            all_good = False
            continue
        ports[entry] = get_port_properties(entry)

    return ports, all_good


PORT_PROPERTIES = ('port', 'version', 'files', 'auth_type')


def get_port_properties(port):
    """Retrieves common port properties from its package.sh file.

    Returns:
        dict: keys are values from PORT_PROPERTIES, values are from the package.sh file
    """

    props = {}
    package_sh_command = f"./package.sh showproperty {' '.join(PORT_PROPERTIES)}"
    res = subprocess.run(f"cd {port}; exec {package_sh_command}", shell=True, capture_output=True)
    if res.returncode == 0:
        results = res.stdout.decode('utf-8').split('\n\n')
        props = {prop: results[i].strip() for i, prop in enumerate(PORT_PROPERTIES)}
    else:
        print((
            f'Executing "{package_sh_command}" script for port {port} failed with '
            f'exit code {res.returncode}, output from stderr:\n{res.stderr.decode("utf-8").strip()}'
        ))
        props = {x: '' for x in PORT_PROPERTIES}
    return props


def check_package_files(ports):
    """Check port package.sh file for required properties.

    Args:
        ports (list): List of all ports to check

    Returns:
        bool: no errors encountered
    """

    all_good = True
    for port in ports.keys():
        package_file = f"{port}/package.sh"
        if not os.path.exists(package_file):
            continue
        props = ports[port]
        if not props['auth_type'] in ('sha256', 'sig', ''):
            print(f"Ports/{port} uses invalid signature algorithm '{props['auth_type']}' for 'auth_type'")
            all_good = False

        for prop in PORT_PROPERTIES:
            if prop == 'auth_type' and re.match('^https://github.com/SerenityPorts/', props["files"]):
                continue
            if props[prop] == '':
                print(f"Ports/{port} is missing required property '{prop}'")
                all_good = False

    return all_good


def get_and_check_port_patch_list(ports):
    """Checks all port patches and returns the port list/properties

    Args:
        ports (list): List of all ports to check

    Returns:
        all_good (bool): No errors encountered
        all_properties (dict): Mapping of port to port properties
    """
    all_port_properties = {}
    all_good = True

    for port in ports:
        patches_directory = f"{port}/patches"

        if not os.path.exists(patches_directory):
            continue

        if not os.path.isdir(patches_directory):
            print(f"Ports/{port}/patches exists, but is not a directory. This is not right!")
            all_good = False
            continue

        patches_path = Path(patches_directory)
        patches_readme_path = patches_path / "ReadMe.md"
        patch_files = set(patches_path.glob("*.patch"))
        non_patch_files = set(patches_path.glob("*")) - patch_files - {patches_readme_path}

        port_properties = {
            "patches_path": patches_path,
            "patches_readme_path": patches_readme_path,
            "patch_files": patch_files,
            "non_patch_files": non_patch_files
        }
        all_port_properties[port] = port_properties

        if len(non_patch_files) != 0:
            print("Ports/{port}/patches contains the following non-patch files:",
                  ', '.join(x.name for x in non_patch_files))
            all_good = False

    return all_good, all_port_properties


def check_descriptions_for_port_patches(patches):
    """Ensure that ports containing patches have them documented.

    Args:
        patches (dict): Dictionary mapping ports to all their patches

    Returns:
        bool: no errors encountered
    """

    all_good = True
    for port, properties in patches.items():
        patches_readme_path = properties["patches_readme_path"]
        patch_files = properties["patch_files"]

        readme_file_exists = patches_readme_path.exists()
        if len(patch_files) == 0:
            print(f"Ports/{port}/patches exists, but contains no patches", end="")
            if readme_file_exists:
                print(", yet it contains a ReadMe.md")
            else:
                print()
            all_good = False
            continue

        if not readme_file_exists:
            if port not in PORTS_MISSING_DESCRIPTIONS:
                print(f"Ports/{port}/patches contains patches but no ReadMe.md describing them")
                all_good = False
            continue

        with open(str(patches_readme_path), 'r', encoding='utf-8') as f:
            readme_contents = []
            for line in f:
                if not line.startswith('#'):
                    continue
                match = PORT_NAME_REGEX.search(line)
                if match:
                    readme_contents.append(match.group(1))

        patch_names = set(Path(x).stem for x in patch_files)

        patches_ok = True
        for patch_name in patch_names:
            if patch_name not in readme_contents:
                if port not in PORTS_MISSING_DESCRIPTIONS:
                    print(f"Ports/{port}/patches/{patch_name}.patch does not appear to be described in"
                          " the corresponding ReadMe.md")
                    all_good = False
                    patches_ok = False

        for patch_name in readme_contents:
            if patch_name not in patch_names:
                if port not in PORTS_MISSING_DESCRIPTIONS:
                    print(f"Ports/{port}/patches/{patch_name}.patch is described in ReadMe.md, "
                          "but does not actually exist")
                    all_good = False
                    patches_ok = False

        if port in PORTS_MISSING_DESCRIPTIONS and patches_ok:
            print(f"Ports/{port}/patches are all described correctly, but the port is marked "
                  "as MISSING_DESCRIPTIONS, make sure to remove it from the list in lint-ports.py")
            all_good = False

    return all_good


def try_parse_git_patch(path_to_patch):
    with open(path_to_patch, 'rb') as f:
        contents_of_patch = f.read()

    with NamedTemporaryFile('r+b') as message_file:
        res = subprocess.run(
            f"git mailinfo {message_file.name} /dev/null",
            shell=True,
            capture_output=True,
            input=contents_of_patch)

        if res.returncode != 0:
            return None

        message = message_file.read().decode('utf-8')
        subject = GIT_PATCH_SUBJECT_RE.search(res.stdout.decode("utf-8"))
        if subject:
            message = subject.group(1) + "\n" + message

        return message


def check_patches_are_git_patches(patches):
    """Ensure that all patches are patches made by (or compatible with) `git format-patch`.

    Args:
        patches (dict): Dictionary mapping ports to all their patches

    Returns:
        bool: no errors encountered
    """

    all_good = True

    for port, properties in patches.items():
        for patch_path in properties["patch_files"]:
            result = try_parse_git_patch(patch_path)
            if not result:
                print(f"Ports/{port}/patches: {patch_path.stem} does not appear to be a valid "
                      "git patch.")
                all_good = False
                continue
    return all_good


def check_available_ports(from_table, ports):
    """Check AvailablePorts.md for correct properties.

    Args:
        from_table (dict): Ports table from AvailablePorts.md
        ports (dict): Dictionary with port properties from package.sh

    Returns:
        bool: no errors encountered
    """

    all_good = True

    previous_line_len = None

    for port in from_table.keys():
        if previous_line_len is None:
            previous_line_len = from_table[port]["line_len"]
        if previous_line_len != from_table[port]["line_len"]:
            print(f"Table row for port {port} is improperly aligned with other rows.")
            all_good = False
        else:
            previous_line_len = from_table[port]["line_len"]

        actual_ref = from_table[port]["dir_ref"]
        expected_ref = f"{port}/"
        if actual_ref != expected_ref:
            print((
                f'Directory link target in AvailablePorts.md for port {port} is '
                f'incorrect, expected "{expected_ref}", found "{actual_ref}"'
            ))
            all_good = False

        actual_version = from_table[port]["version"]
        expected_version = ports[port]["version"]
        if GIT_HASH_REGEX.match(expected_version):
            expected_version = expected_version[0:7]
        if expected_version == "git":
            expected_version = ""
        if actual_version != expected_version:
            print((
                f'Version in AvailablePorts.md for port {port} is incorrect, '
                f'expected "{expected_version}", found "{actual_version}"'
            ))
            all_good = False

    return all_good


def run():
    """Check Ports directory and package files for errors."""

    from_table = read_port_table(PORT_TABLE_FILE)
    ports, all_good = read_port_dirs()

    from_table_set = set(from_table.keys())
    ports_set = set(ports.keys())

    if from_table_set - ports_set:
        all_good = False
        print('AvailablePorts.md lists ports that do not appear in the file system:')
        for port in sorted(from_table_set - ports_set):
            print(f"    {port}")

    if ports_set - from_table_set:
        all_good = False
        print('AvailablePorts.md is missing the following ports:')
        for port in sorted(ports_set - from_table_set):
            print(f"    {port}")

    if not check_package_files(ports):
        all_good = False

    if not check_available_ports(from_table, ports):
        all_good = False

    patch_list_good, port_properties = get_and_check_port_patch_list(ports.keys())
    all_good = all_good and patch_list_good

    if not check_descriptions_for_port_patches(port_properties):
        all_good = False

    if REQUIRE_GIT_PATCHES and not check_patches_are_git_patches(port_properties):
        all_good = False

    if not all_good:
        sys.exit(1)

    print('No issues found.')


if __name__ == '__main__':
    os.chdir(f"{os.path.dirname(__file__)}/../Ports")
    run()