#!/usr/bin/python3
#
# OBS Source Service to enforce correct configuration of
# rust projects for security maintenance.
#
# (C) 2021 William Brown <william at blackhats.net.au>

"""\
This OBS source validator is designed to assist our security
teams in maintenance and resolving of security issues related
to rust packages. This is to improve the standard of rust
packaging in OpenSUSE

This validator enforces that when a program depends on rust,
cargo or cargo-packaging:

* That cargo_audit is configured
* If cargo_vendor is configured, update=true is set

If a package depends on rust-packaging, a warning is emitted
that the package needs to migrate to cargo-packaging.
"""

import argparse
import sys
import os
from enum import Enum
from pathlib import Path
import xml.etree.ElementTree as ET

description = __doc__

VERBOSE = os.getenv('DEBUG') is not None

# Some packages are exempt from this validation. These should be documented inline as to *why*.
IGNORE_PACKAGES = [
    # the advisory db depends on cargo, and thus rust as it is a subcommand of these. However
    # it has no rust sources itself, so should NOT be subject to the same requirements.
    "cargo-audit-advisory-db",
    # obs services depend on cargo but do not include rust sources themself and so are exempt
    "obs-service-cargo_vendor",
    "obs-service-cargo_audit",
]

class Result(Enum):
    Fail = 0
    Pass = 1
    Warn = 2

def process_spec_file(specpath):
    reports = []

    if VERBOSE:
        print("Processing: %s" % specpath)

    f_contents = []
    with open(specpath, 'r') as f:
        f_contents = f.readlines()

    name = None
    for l in f_contents:
        if l.startswith("Name:"):
            name = l.split(":", 1)[1].strip()
            break

    if name is None:
        reports.append((specpath, Result.Fail, "specfile missing 'Name:'"))
        return reports

    if name in IGNORE_PACKAGES:
        if VERBOSE:
            print("skipping exempt package: %s" % name)

    # Build the set of buildrequries
    buildrequires = [
        l.split(":", 1)[1].strip()
        for l in f_contents
        if l.startswith("BuildRequires:")
    ]

    requires_rust = any([
        x in ('cargo', 'rust', 'cargo-packaging', 'rust+cargo', 'rust-packaging')
        for x in buildrequires
    ])

    if VERBOSE:
        print("%s requires_rust: %s" % (name, requires_rust))

    rust_packaging = any([
        x == "rust-packaging"
        for x in buildrequires
    ])

    if rust_packaging:
        reports.append((name, Result.Fail, "rust-packaging is deprecated - you must immediately convert to cargo-packaging"))

    if requires_rust:
        # Given the spec path, get the work dir.
        specpath_abs = os.path.abspath(specpath)
        workdir = Path(specpath_abs).parent
        service = os.path.join(workdir, "_service")
        if not os.path.exists(service):
            reports.append((name, Result.Warn, "package depends on rust but does not have obs services configured with cargo_vendor"))
            return reports
        # Then process the _service.
        has_vendor = False
        # Cargo vendor now defaults to true here.
        has_vendor_update = True

        tree = ET.parse(service)
        root_node = tree.getroot()
        for tag in root_node.findall('service'):
            if tag.attrib['name'] == 'cargo_vendor':
                has_vendor = True
                for attr in tag:
                    if attr.attrib['name'] == 'update' and attr.text != 'true':
                        has_vendor_update = False

        if has_vendor and not has_vendor_update:
            reports.append((name, Result.Warn, "package uses cargo_vendor, but is missing '<param name=\"update\">true</param>' to apply security updates"))

    return reports


def main():
    parser = argparse.ArgumentParser(
        description=description, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--batchmode", default=False, action='store_true')
    parser.add_argument("srcdir", default=str(Path.cwd()), nargs='?')
    # We always ignore this parameter, but it has to exist.
    parser.add_argument("outdir", default=None, nargs='?')
    args = parser.parse_args()

    if VERBOSE:
        print("%s" % args)

    # For each spec file, process it.
    reports = [process_spec_file(x) for x in Path(args.srcdir).glob("*.spec")]

    reports = [x for report in reports for x in report]

    for report in reports:
        print("%s: %s" % (report[0], report[2]))

    if any([x[1] == Result.Fail for x in reports]):
        print("Rust Source Validator: Failed")
        sys.exit(1)
    else:
        if VERBOSE:
            print("Rust Source Validator: Pass")
        sys.exit(0)

if __name__ == "__main__":
    main()

