| #!/usr/bin/env python3 | 
 | # | 
 | # Copyright (C) 2020 The Android Open Source Project | 
 | # | 
 | # Licensed under the Apache License, Version 2.0 (the "License"); | 
 | # you may not use this file except in compliance with the License. | 
 | # You may obtain a copy of the License at | 
 | # | 
 | #      http://www.apache.org/licenses/LICENSE-2.0 | 
 | # | 
 | # Unless required by applicable law or agreed to in writing, software | 
 | # distributed under the License is distributed on an "AS IS" BASIS, | 
 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
 | # See the License for the specific language governing permissions and | 
 | # limitations under the License. | 
 | """Add files to a Rust package for third party review.""" | 
 |  | 
 | import collections | 
 | import datetime | 
 | import enum | 
 | import glob | 
 | import json | 
 | import os | 
 | import pathlib | 
 | import re | 
 |  | 
 | # patterns to match keys in Cargo.toml | 
 | NAME_PATTERN = r"^name *= *\"(.+)\"" | 
 | NAME_MATCHER = re.compile(NAME_PATTERN) | 
 | VERSION_PATTERN = r"^version *= *\"(.+)\"" | 
 | VERSION_MATCHER = re.compile(VERSION_PATTERN) | 
 | DESCRIPTION_PATTERN = r"^description *= *(\".+\")" | 
 | DESCRIPTION_MATCHER = re.compile(DESCRIPTION_PATTERN) | 
 | # NOTE: This description one-liner pattern fails to match | 
 | # multi-line descriptions in some Rust crates, e.g. shlex. | 
 | LICENSE_PATTERN = r"^license *= *\"(.+)\"" | 
 | LICENSE_MATCHER = re.compile(LICENSE_PATTERN) | 
 |  | 
 | # patterns to match year/month/day in METADATA | 
 | YMD_PATTERN = r"^ +(year|month|day): (.+)$" | 
 | YMD_MATCHER = re.compile(YMD_PATTERN) | 
 | YMD_LINE_PATTERN = r"^.* year: *([^ ]+) +month: *([^ ]+) +day: *([^ ]+).*$" | 
 | YMD_LINE_MATCHER = re.compile(YMD_LINE_PATTERN) | 
 |  | 
 | # patterns to match different licence types in LICENSE* | 
 | APACHE_PATTERN = r"^.*Apache License.*$" | 
 | APACHE_MATCHER = re.compile(APACHE_PATTERN) | 
 | BOOST_PATTERN = r"^.Boost Software License.*Version 1.0.*$" | 
 | BOOST_MATCHER = re.compile(BOOST_PATTERN) | 
 | MIT_PATTERN = r"^.*MIT License.*$" | 
 | MIT_MATCHER = re.compile(MIT_PATTERN) | 
 | BSD_PATTERN = r"^.*BSD .*License.*$" | 
 | BSD_MATCHER = re.compile(BSD_PATTERN) | 
 | MPL_PATTERN = r"^.Mozilla Public License.*$" | 
 | MPL_MATCHER = re.compile(MPL_PATTERN) | 
 | UNLICENSE_PATTERN = r"^.*unlicense\.org.*$" | 
 | UNLICENSE_MATCHER = re.compile(UNLICENSE_PATTERN) | 
 | ZERO_BSD_PATTERN = r"^.*Zero-Clause BSD.*$" | 
 | ZERO_BSD_MATCHER = re.compile(ZERO_BSD_PATTERN) | 
 | ZLIB_PATTERN = r"^.*zlib License.$" | 
 | ZLIB_MATCHER = re.compile(ZLIB_PATTERN) | 
 | MULTI_LICENSE_COMMENT = ("# Dual-licensed, using the least restrictive " | 
 |         "per go/thirdpartylicenses#same.\n  ") | 
 |  | 
 | # default owners added to OWNERS | 
 | DEFAULT_OWNERS = "include platform/prebuilts/rust:main:/OWNERS\n" | 
 |  | 
 | # See b/159487435 Official policy for rust imports METADATA URLs. | 
 | # "license_type: NOTICE" might be optional, | 
 | # but it is already used in most rust crate METADATA. | 
 | # This line format should match the output of external_updater. | 
 | METADATA_CONTENT = """name: "{name}" | 
 | description: {description} | 
 | third_party {{ | 
 |   identifier {{ | 
 |     type: "crates.io" | 
 |     value: "{name}" | 
 |   }} | 
 |   identifier {{ | 
 |     type: "Archive" | 
 |     value: "https://static.crates.io/crates/{name}/{name}-{version}.crate" | 
 |     primary_source: true | 
 |   }} | 
 |   version: "{version}" | 
 |   {license_comment}license_type: {license_type} | 
 |   last_upgrade_date {{ | 
 |     year: {year} | 
 |     month: {month} | 
 |     day: {day} | 
 |   }} | 
 | }} | 
 | """ | 
 |  | 
 |  | 
 | def get_metadata_date(): | 
 |   """Return last_upgrade_date in METADATA or today.""" | 
 |   # When applied to existing directories to normalize METADATA, | 
 |   # we don't want to change the last_upgrade_date. | 
 |   year, month, day = "", "", "" | 
 |   if os.path.exists("METADATA"): | 
 |     with open("METADATA", "r") as inf: | 
 |       for line in inf: | 
 |         match = YMD_MATCHER.match(line) | 
 |         if match: | 
 |           if match.group(1) == "year": | 
 |             year = match.group(2) | 
 |           elif match.group(1) == "month": | 
 |             month = match.group(2) | 
 |           elif match.group(1) == "day": | 
 |             day = match.group(2) | 
 |         else: | 
 |           match = YMD_LINE_MATCHER.match(line) | 
 |           if match: | 
 |             year, month, day = match.group(1), match.group(2), match.group(3) | 
 |   if year and month and day: | 
 |     print("### Reuse date in METADATA:", year, month, day) | 
 |     return int(year), int(month), int(day) | 
 |   today = datetime.date.today() | 
 |   return today.year, today.month, today.day | 
 |  | 
 |  | 
 | def add_metadata(name, version, description, license_group, multi_license): | 
 |   """Update or add METADATA file.""" | 
 |   if os.path.exists("METADATA"): | 
 |     print("### Updating METADATA") | 
 |   else: | 
 |     print("### Adding METADATA") | 
 |   year, month, day = get_metadata_date() | 
 |   license_comment = "" | 
 |   if multi_license: | 
 |     license_comment = MULTI_LICENSE_COMMENT | 
 |   with open("METADATA", "w") as outf: | 
 |     outf.write(METADATA_CONTENT.format( | 
 |         name=name, description=description, version=version, | 
 |         license_comment=license_comment, license_type=license_group, year=year, month=month, day=day)) | 
 |  | 
 |  | 
 | def grep_license_keyword(license_file): | 
 |   """Find familiar patterns in a file and return the type.""" | 
 |   with open(license_file, "r") as input_file: | 
 |     for line in input_file: | 
 |       if APACHE_MATCHER.match(line): | 
 |         return License(LicenseType.APACHE2, LicenseGroup.NOTICE, license_file) | 
 |       if BOOST_MATCHER.match(line): | 
 |         return License(LicenseType.BOOST, LicenseGroup.NOTICE, license_file) | 
 |       if MIT_MATCHER.match(line): | 
 |         return License(LicenseType.MIT, LicenseGroup.NOTICE, license_file) | 
 |       if BSD_MATCHER.match(line): | 
 |         return License(LicenseType.BSD_LIKE, LicenseGroup.NOTICE, license_file) | 
 |       if MPL_MATCHER.match(line): | 
 |         return License(LicenseType.MPL, LicenseGroup.RECIPROCAL, license_file) | 
 |       if UNLICENSE_MATCHER.match(line): | 
 |         return License(LicenseType.UNLICENSE, LicenseGroup.PERMISSIVE, license_file) | 
 |       if ZERO_BSD_MATCHER.match(line): | 
 |         return License(LicenseType.ZERO_BSD, LicenseGroup.PERMISSIVE, license_file) | 
 |       if ZLIB_MATCHER.match(line): | 
 |         return License(LicenseType.ZLIB, LicenseGroup.NOTICE, license_file) | 
 |   print("ERROR: cannot decide license type in", license_file, | 
 |         "assume BSD_LIKE") | 
 |   return License(LicenseType.BSD_LIKE, LicenseGroup.NOTICE, license_file) | 
 |  | 
 |  | 
 | class LicenseType(enum.IntEnum): | 
 |   """A type of license. | 
 |  | 
 |   An IntEnum is used to be able to sort by preference. This is mainly the case | 
 |   for dual-licensed Apache/MIT code, for which we prefer the Apache license. | 
 |   The enum name is used to generate the corresponding MODULE_LICENSE_* file. | 
 |   """ | 
 |   APACHE2 = 1 | 
 |   MIT = 2 | 
 |   BSD_LIKE = 3 | 
 |   ISC = 4 | 
 |   MPL = 5 | 
 |   ZERO_BSD = 6 | 
 |   UNLICENSE = 7 | 
 |   ZLIB = 8 | 
 |   BOOST = 9 | 
 |  | 
 | class LicenseGroup(enum.Enum): | 
 |   """A group of license as defined by go/thirdpartylicenses#types | 
 |  | 
 |   Note, go/thirdpartylicenses#types calls them "types". But LicenseType was | 
 |   already taken so this script calls them groups. | 
 |   """ | 
 |   RESTRICTED = 1 | 
 |   RESTRICTED_IF_STATICALLY_LINKED = 2 | 
 |   RECIPROCAL = 3 | 
 |   NOTICE = 4 | 
 |   PERMISSIVE = 5 | 
 |   BY_EXCEPTION_ONLY = 6 | 
 |  | 
 |  | 
 | License = collections.namedtuple('License', ['type', 'group', 'filename']) | 
 |  | 
 |  | 
 | def decide_license_type(cargo_license): | 
 |   """Check LICENSE* files to determine the license type. | 
 |  | 
 |   Returns: A list of Licenses. The first element is the license we prefer. | 
 |   """ | 
 |   # Most crates.io packages have both APACHE and MIT. | 
 |   # Some crate like time-macros-impl uses lower case names like LICENSE-Apache. | 
 |   licenses = [] | 
 |   license_file = None | 
 |   for license_file in glob.glob("LICENSE*") + glob.glob("COPYING*") + glob.glob("UNLICENSE*"): | 
 |     lowered_name = os.path.splitext(license_file.lower())[0] | 
 |     if lowered_name == "license-apache": | 
 |       licenses.append(License(LicenseType.APACHE2, LicenseGroup.NOTICE, license_file)) | 
 |     elif lowered_name == "license-boost": | 
 |       licenses.append(License(LicenseType.BOOST, LicenseGroup.NOTICE, license_file)) | 
 |     elif lowered_name == "license-bsd": | 
 |       licenses.append(License(LicenseType.BSD_LIKE, LicenseGroup.NOTICE, license_file)) | 
 |     elif lowered_name == "license-mit": | 
 |       licenses.append(License(LicenseType.MIT, LicenseGroup.NOTICE, license_file)) | 
 |     elif lowered_name == "license-0bsd": | 
 |       licenses.append(License(LicenseType.ZERO_BSD, LicenseGroup.PERMISSIVE, license_file)) | 
 |     elif lowered_name == "license-zlib": | 
 |       licenses.append(License(LicenseType.ZLIB, LicenseGroup.NOTICE, license_file)) | 
 |     elif lowered_name == "unlicense": | 
 |       licenses.append(License(LicenseType.UNLICENSE, LicenseGroup.PERMISSIVE, license_file)) | 
 |   if licenses: | 
 |     licenses.sort(key=lambda l: l.type) | 
 |     return licenses | 
 |   if not license_file: | 
 |     raise FileNotFoundError("No license file has been found.") | 
 |   # There is a LICENSE* or COPYING* file, use cargo_license found in | 
 |   # Cargo.toml. | 
 |   if "Apache" in cargo_license: | 
 |     return [License(LicenseType.APACHE2, LicenseGroup.NOTICE, license_file)] | 
 |   if "BSL" in cargo_license: | 
 |     return [License(LicenseType.BOOST, LicenseGroup.NOTICE, license_file)] | 
 |   if "MIT" in cargo_license: | 
 |     return [License(LicenseType.MIT, LicenseGroup.NOTICE, license_file)] | 
 |   if "0BSD" in cargo_license: | 
 |     return [License(LicenseType.ZERO_BSD, LicenseGroup.PERMISSIVE, license_file)] | 
 |   if "BSD" in cargo_license: | 
 |     return [License(LicenseType.BSD_LIKE, LicenseGroup.NOTICE, license_file)] | 
 |   if "ISC" in cargo_license: | 
 |     return [License(LicenseType.ISC, LicenseGroup.NOTICE, license_file)] | 
 |   if "MPL" in cargo_license: | 
 |     return [License(LicenseType.MPL, LicenseGroup.RECIPROCAL, license_file)] | 
 |   if "Unlicense" in cargo_license: | 
 |     return [License(LicenseType.UNLICENSE, LicenseGroup.PERMISSIVE, license_file)] | 
 |   if "Zlib" in cargo_license: | 
 |     return [License(LicenseType.ZLIB, LicenseGroup.NOTICE, license_file)] | 
 |   return [grep_license_keyword(license_file)] | 
 |  | 
 |  | 
 | def add_notice(): | 
 |   if not os.path.exists("NOTICE"): | 
 |     if os.path.exists("LICENSE"): | 
 |       os.symlink("LICENSE", "NOTICE") | 
 |       print("Created link from NOTICE to LICENSE") | 
 |     else: | 
 |       print("ERROR: missing NOTICE and LICENSE") | 
 |  | 
 |  | 
 | def check_license_link(target): | 
 |   """Check the LICENSE link, must bet the given target.""" | 
 |   if not os.path.islink("LICENSE"): | 
 |     print("ERROR: LICENSE file is not a link") | 
 |     return | 
 |   found_target = os.readlink("LICENSE") | 
 |   if target != found_target and found_target != "LICENSE.txt": | 
 |     print("ERROR: found LICENSE link to", found_target, | 
 |           "but expected", target) | 
 |  | 
 |  | 
 | def add_license(target): | 
 |   """Add LICENSE link to give target.""" | 
 |   if os.path.exists("LICENSE"): | 
 |     if os.path.islink("LICENSE"): | 
 |       check_license_link(target) | 
 |     else: | 
 |       print("NOTE: found LICENSE and it is not a link.") | 
 |     return | 
 |   print("### Creating LICENSE link to", target) | 
 |   os.symlink(target, "LICENSE") | 
 |  | 
 |  | 
 | def add_module_license(license_type): | 
 |   """Touch MODULE_LICENSE_type file.""" | 
 |   # Do not change existing MODULE_* files. | 
 |   for suffix in ["MIT", "APACHE", "APACHE2", "BSD_LIKE", "MPL", "0BSD", "UNLICENSE", "ZLIB", "BOOST"]: | 
 |     module_file = "MODULE_LICENSE_" + suffix | 
 |     if os.path.exists(module_file): | 
 |       if license_type.name != suffix: | 
 |         raise Exception("Found unexpected license " + module_file) | 
 |       return | 
 |   module_file = "MODULE_LICENSE_" + license_type.name.upper() | 
 |   pathlib.Path(module_file).touch() | 
 |   print("### Touched", module_file) | 
 |  | 
 |  | 
 | def found_line(file_name, line): | 
 |   """Returns true if the given line is found in a file.""" | 
 |   with open(file_name, "r") as input_file: | 
 |     return line in input_file | 
 |  | 
 |  | 
 | def add_owners(): | 
 |   """Create or append OWNERS with the default owner line.""" | 
 |   # Existing OWNERS file might contain more than the default owners. | 
 |   # Only append missing default owners to existing OWNERS. | 
 |   if os.path.isfile("OWNERS"): | 
 |     if found_line("OWNERS", DEFAULT_OWNERS): | 
 |       print("### No change to OWNERS, which has already default owners.") | 
 |       return | 
 |     else: | 
 |       print("### Append default owners to OWNERS") | 
 |       mode = "a" | 
 |   else: | 
 |     print("### Creating OWNERS with default owners") | 
 |     mode = "w" | 
 |   with open("OWNERS", mode) as outf: | 
 |     outf.write(DEFAULT_OWNERS) | 
 |  | 
 |  | 
 | def toml2json(line): | 
 |   """Convert a quoted toml string to a json quoted string for METADATA.""" | 
 |   if line.startswith("\"\"\""): | 
 |     return "\"()\""  # cannot handle broken multi-line description | 
 |   # TOML string escapes: \b \t \n \f \r \" \\ (no unicode escape) | 
 |   line = line[1:-1].replace("\\\\", "\n").replace("\\b", "") | 
 |   line = line.replace("\\t", " ").replace("\\n", " ").replace("\\f", " ") | 
 |   line = line.replace("\\r", "").replace("\\\"", "\"").replace("\n", "\\") | 
 |   # replace a unicode quotation mark, used in the libloading crate | 
 |   line = line.replace("’", "'") | 
 |   # strip and escape single quotes | 
 |   return json.dumps(line.strip()).replace("'", "\\'") | 
 |  | 
 |  | 
 | def parse_cargo_toml(cargo): | 
 |   """get name, version, description, license string from Cargo.toml.""" | 
 |   name = "" | 
 |   version = "" | 
 |   description = "" | 
 |   cargo_license = "" | 
 |   with open(cargo, "r") as toml: | 
 |     for line in toml: | 
 |       if not name and NAME_MATCHER.match(line): | 
 |         name = NAME_MATCHER.match(line).group(1) | 
 |       elif not version and VERSION_MATCHER.match(line): | 
 |         version = VERSION_MATCHER.match(line).group(1) | 
 |       elif not description and DESCRIPTION_MATCHER.match(line): | 
 |         description = toml2json(DESCRIPTION_MATCHER.match(line).group(1)) | 
 |       elif not cargo_license and LICENSE_MATCHER.match(line): | 
 |         cargo_license = LICENSE_MATCHER.match(line).group(1) | 
 |       if name and version and description and cargo_license: | 
 |         break | 
 |   return name, version, description, cargo_license | 
 |  | 
 |  | 
 | def main(): | 
 |   """Add 3rd party review files.""" | 
 |   cargo = "Cargo.toml" | 
 |   if not os.path.isfile(cargo): | 
 |     print("ERROR: ", cargo, "is not found") | 
 |     return | 
 |   if not os.access(cargo, os.R_OK): | 
 |     print("ERROR: ", cargo, "is not readable") | 
 |     return | 
 |   name, version, description, cargo_license = parse_cargo_toml(cargo) | 
 |   if not name or not version or not description: | 
 |     print("ERROR: Cannot find name, version, or description in", cargo) | 
 |     return | 
 |   print("### Cargo.toml license:", cargo_license) | 
 |   licenses = decide_license_type(cargo_license) | 
 |   preferred_license = licenses[0] | 
 |   add_metadata(name, version, description, preferred_license.group.name, len(licenses) > 1) | 
 |   add_owners() | 
 |   add_license(preferred_license.filename) | 
 |   add_module_license(preferred_license.type) | 
 |   # It is unclear yet if a NOTICE file is required. | 
 |   # add_notice() | 
 |  | 
 |  | 
 | if __name__ == "__main__": | 
 |   main() |