blob: 6b1b13c11a6fd5a24b0a57241cdf72a95c5999d4 [file] [log] [blame]
Chih-Hung Hsieh3d24aed2020-10-05 15:29:11 -07001#!/usr/bin/env python3
2#
3# Copyright (C) 2020 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16"""Add files to a Rust package for third party review."""
17
18import datetime
19import json
20import os
21import pathlib
22import re
23
24# patterns to match keys in Cargo.toml
25NAME_PATTERN = r"^name *= *\"(.+)\""
26NAME_MATCHER = re.compile(NAME_PATTERN)
27VERSION_PATTERN = r"^version *= *\"(.+)\""
28VERSION_MATCHER = re.compile(VERSION_PATTERN)
29DESCRIPTION_PATTERN = r"^description *= *(\".+\")"
30DESCRIPTION_MATCHER = re.compile(DESCRIPTION_PATTERN)
31# NOTE: This description one-liner pattern fails to match
32# multi-line descriptions in some Rust crates, e.g. shlex.
33
34# patterns to match year/month/day in METADATA
35YMD_PATTERN = r"^ +(year|month|day): (.+)$"
36YMD_MATCHER = re.compile(YMD_PATTERN)
37YMD_LINE_PATTERN = r"^.* year: *([^ ]+) +month: *([^ ]+) +day: *([^ ]+).*$"
38YMD_LINE_MATCHER = re.compile(YMD_LINE_PATTERN)
39
40# patterns to match Apache/MIT licence in LICENSE*
41APACHE_PATTERN = r"^.*Apache License.*$"
42APACHE_MATCHER = re.compile(APACHE_PATTERN)
43MIT_PATTERN = r"^.*MIT License.*$"
44MIT_MATCHER = re.compile(MIT_PATTERN)
45BSD_PATTERN = r"^.*BSD .*License.*$"
46BSD_MATCHER = re.compile(BSD_PATTERN)
47
48# default owners added to OWNERS
49DEFAULT_OWNERS = "include platform/prebuilts/rust:/OWNERS\n"
50
51# See b/159487435 Official policy for rust imports METADATA URLs.
52# "license_type: NOTICE" might be optional,
53# but it is already used in most rust crate METADATA.
54# This line format should match the output of external_updater.
55METADATA_CONTENT = """name: "{}"
56description: {}
57third_party {{
58 url {{
59 type: HOMEPAGE
60 value: "https://crates.io/crates/{}"
61 }}
62 url {{
63 type: ARCHIVE
64 value: "https://static.crates.io/crates/{}/{}-{}.crate"
65 }}
66 version: "{}"
67 license_type: NOTICE
68 last_upgrade_date {{
69 year: {}
70 month: {}
71 day: {}
72 }}
73}}
74"""
75
76
77def get_metadata_date():
78 """Return last_upgrade_date in METADATA or today."""
79 # When applied to existing directories to normalize METADATA,
80 # we don't want to change the last_upgrade_date.
81 year, month, day = "", "", ""
82 if os.path.exists("METADATA"):
83 with open("METADATA", "r") as inf:
84 for line in inf:
85 match = YMD_MATCHER.match(line)
86 if match:
87 if match.group(1) == "year":
88 year = match.group(2)
89 elif match.group(1) == "month":
90 month = match.group(2)
91 elif match.group(1) == "day":
92 day = match.group(2)
93 else:
94 match = YMD_LINE_MATCHER.match(line)
95 if match:
96 year, month, day = match.group(1), match.group(2), match.group(3)
97 if year and month and day:
98 print("### Reuse date in METADATA:", year, month, day)
99 return int(year), int(month), int(day)
100 today = datetime.date.today()
101 return today.year, today.month, today.day
102
103
104def add_metadata(name, version, description):
105 """Update or add METADATA file."""
106 if os.path.exists("METADATA"):
107 print("### Updating METADATA")
108 else:
109 print("### Adding METADATA")
110 year, month, day = get_metadata_date()
111 with open("METADATA", "w") as outf:
112 outf.write(METADATA_CONTENT.format(
113 name, description, name, name, name,
114 version, version, year, month, day))
115
116
117def grep_license_keyword(license_file):
118 """Find familiar patterns in a file and return the type."""
119 with open(license_file, "r") as input_file:
120 for line in input_file:
121 if APACHE_MATCHER.match(line):
122 return "APACHE2"
123 if MIT_MATCHER.match(line):
124 return "MIT"
125 if BSD_MATCHER.match(line):
126 return "BSD_LIKE"
127 print("ERROR: cannot decide license type in", license_file,
128 " assume BSD_LIKE")
129 return "BSD_LIKE"
130
131
132def decide_license_type():
133 """Check LICENSE* files to determine the license type."""
134 # Most crates.io packages have both APACHE and MIT.
135 if os.path.exists("LICENSE-APACHE"):
136 return "APACHE2"
137 if os.path.exists("LICENSE-MIT"):
138 return "MIT"
139 for license_file in ["LICENSE", "LICENSE.txt"]:
140 if os.path.exists(license_file):
141 return grep_license_keyword(license_file)
142 print("ERROR: missing LICENSE-{APACHE,MIT}; assume BSD_LIKE")
143 return "BSD_LIKE"
144
145
146def add_notice():
147 if not os.path.exists("NOTICE"):
148 if os.path.exists("LICENSE"):
149 os.symlink("LICENSE", "NOTICE")
150 print("Created link from NOTICE to LICENSE")
151 else:
152 print("ERROR: missing NOTICE and LICENSE")
153
154
155def license_link_target(license_type):
156 """Return the LICENSE-* target file for LICENSE link."""
157 if license_type == "APACHE2":
158 return "LICENSE-APACHE"
159 elif license_type == "MIT":
160 return "LICENSE-MIT"
161 elif license_type == "BSD_LIKE":
162 for name in ["LICENSE.txt"]:
163 if os.path.exists(name):
164 return name
165 print("### ERROR: cannot find LICENSE target")
166 return ""
167 else:
168 print("### ERROR; unknown license type:", license_type)
169 return ""
170
171
172def check_license_link(license_type):
173 """Check the LICENSE link, must match given type."""
174 if not os.path.islink("LICENSE"):
175 print("ERROR: LICENSE file is not a link")
176 return
177 target = os.readlink("LICENSE")
178 expected = license_link_target(license_type)
179 if target != expected:
180 print("ERROR: found LICENSE link to", target,
181 "but expected", expected)
182
183
184def add_license(license_type):
185 """Add LICENSE related file."""
186 if os.path.exists("LICENSE"):
187 if os.path.islink("LICENSE"):
188 check_license_link(license_type)
189 else:
190 print("NOTE: found LICENSE and it is not a link!")
191 return
192 target = license_link_target(license_type)
193 print("### Creating LICENSE link to", target)
194 if target:
195 os.symlink(target, "LICENSE")
196 # error reported in license_link_target
197
198
199def add_module_license(license_type):
200 """Touch MODULE_LICENSE_type file."""
201 # Do not change existing MODULE_* files.
202 for suffix in ["MIT", "APACHE", "APACHE2", "BSD_LIKE"]:
203 module_file = "MODULE_LICENSE_" + suffix
204 if os.path.exists(module_file):
205 if license_type != suffix:
206 print("### ERROR: found unexpected", module_file)
207 return
208 module_file = "MODULE_LICENSE_" + license_type
209 pathlib.Path(module_file).touch()
210 print("### Touched", module_file)
211
212
213def found_line(file_name, line):
214 """Returns true if the given line is found in a file."""
215 with open(file_name, "r") as input_file:
216 return line in input_file
217
218
219def add_owners():
220 """Create or append OWNERS with the default owner line."""
221 # Existing OWNERS file might contain more than the default owners.
222 # Only append missing default owners to existing OWNERS.
223 if os.path.isfile("OWNERS"):
224 if found_line("OWNERS", DEFAULT_OWNERS):
225 print("### No change to OWNERS, which has already default owners.")
226 return
227 else:
228 print("### Append default owners to OWNERS")
229 mode = "a"
230 else:
231 print("### Creating OWNERS with default owners")
232 mode = "w"
233 with open("OWNERS", mode) as outf:
234 outf.write(DEFAULT_OWNERS)
235
236
237def toml2json(line):
238 """Convert a quoted toml string to a json quoted string for METADATA."""
239 if line.startswith("\"\"\""):
240 return "\"()\"" # cannot handle broken multi-line description
241 # TOML string escapes: \b \t \n \f \r \" \\ (no unicode escape)
242 line = line[1:-1].replace("\\\\", "\n").replace("\\b", "")
243 line = line.replace("\\t", " ").replace("\\n", " ").replace("\\f", " ")
244 line = line.replace("\\r", "").replace("\\\"", "\"").replace("\n", "\\")
245 # replace a unicode quotation mark, used in the libloading crate
246 line = line.replace("’", "'")
247 # strip and escape single quotes
248 return json.dumps(line.strip()).replace("'", "\\'")
249
250
251def parse_cargo_toml(cargo):
252 """get description string from Cargo.toml."""
253 name = ""
254 version = ""
255 description = ""
256 with open(cargo, "r") as toml:
257 for line in toml:
258 if not name:
259 match = NAME_MATCHER.match(line)
260 if match:
261 name = match.group(1)
262 if not version:
263 match = VERSION_MATCHER.match(line)
264 if match:
265 version = match.group(1)
266 if not description:
267 match = DESCRIPTION_MATCHER.match(line)
268 if match:
269 description = toml2json(match.group(1))
270 if name and version and description:
271 break
272 return name, version, description
273
274
275def main():
276 """Add 3rd party review files."""
277 cargo = "Cargo.toml"
278 if not os.path.isfile(cargo):
279 print("ERROR: ", cargo, "is not found")
280 return
281 if not os.access(cargo, os.R_OK):
282 print("ERROR: ", cargo, "is not readable")
283 return
284 name, version, description = parse_cargo_toml(cargo)
285 if not name or not version or not description:
286 print("ERROR: Cannot find name, version, or description in", cargo)
287 return
288 add_metadata(name, version, description)
289 add_owners()
290 license_type = decide_license_type()
291 add_license(license_type)
292 add_module_license(license_type)
293 # It is unclear yet if a NOTICE file is required.
294 # add_notice()
295
296
297if __name__ == "__main__":
298 main()