blob: 47898509ff930df2d36db4b80c3f5d3232c0d987 [file] [log] [blame]
Andrew Geissler82c905d2020-04-13 13:39:40 -05001# Copyright (C) 2020 Savoir-Faire Linux
Brad Bishopc342db32019-05-15 21:57:59 -04002#
3# SPDX-License-Identifier: GPL-2.0-only
4#
Patrick Williamsd8c66bc2016-06-20 12:57:21 -05005"""
Andrew Geissler82c905d2020-04-13 13:39:40 -05006BitBake 'Fetch' npm implementation
Patrick Williamsd8c66bc2016-06-20 12:57:21 -05007
Andrew Geissler82c905d2020-04-13 13:39:40 -05008npm fetcher support the SRC_URI with format of:
9SRC_URI = "npm://some.registry.url;OptionA=xxx;OptionB=xxx;..."
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050010
Andrew Geissler82c905d2020-04-13 13:39:40 -050011Supported SRC_URI options are:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050012
Andrew Geissler82c905d2020-04-13 13:39:40 -050013- package
14 The npm package name. This is a mandatory parameter.
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050015
Andrew Geissler82c905d2020-04-13 13:39:40 -050016- version
17 The npm package version. This is a mandatory parameter.
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050018
Andrew Geissler82c905d2020-04-13 13:39:40 -050019- downloadfilename
20 Specifies the filename used when storing the downloaded file.
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050021
Andrew Geissler82c905d2020-04-13 13:39:40 -050022- destsuffix
23 Specifies the directory to use to unpack the package (default: npm).
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050024"""
25
Andrew Geissler82c905d2020-04-13 13:39:40 -050026import base64
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050027import json
Andrew Geissler82c905d2020-04-13 13:39:40 -050028import os
29import re
30import shlex
31import tempfile
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050032import bb
Andrew Geissler82c905d2020-04-13 13:39:40 -050033from bb.fetch2 import Fetch
34from bb.fetch2 import FetchError
35from bb.fetch2 import FetchMethod
36from bb.fetch2 import MissingParameterError
37from bb.fetch2 import ParameterError
38from bb.fetch2 import URI
39from bb.fetch2 import check_network_access
40from bb.fetch2 import runfetchcmd
41from bb.utils import is_semver
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050042
Andrew Geissler82c905d2020-04-13 13:39:40 -050043def npm_package(package):
44 """Convert the npm package name to remove unsupported character"""
45 # Scoped package names (with the @) use the same naming convention
46 # as the 'npm pack' command.
47 if package.startswith("@"):
48 return re.sub("/", "-", package[1:])
49 return package
50
51def npm_filename(package, version):
52 """Get the filename of a npm package"""
53 return npm_package(package) + "-" + version + ".tgz"
54
55def npm_localfile(package, version):
56 """Get the local filename of a npm package"""
57 return os.path.join("npm2", npm_filename(package, version))
58
59def npm_integrity(integrity):
60 """
61 Get the checksum name and expected value from the subresource integrity
62 https://www.w3.org/TR/SRI/
63 """
64 algo, value = integrity.split("-", maxsplit=1)
65 return "%ssum" % algo, base64.b64decode(value).hex()
66
67def npm_unpack(tarball, destdir, d):
68 """Unpack a npm tarball"""
69 bb.utils.mkdirhier(destdir)
70 cmd = "tar --extract --gzip --file=%s" % shlex.quote(tarball)
71 cmd += " --no-same-owner"
72 cmd += " --strip-components=1"
73 runfetchcmd(cmd, d, workdir=destdir)
74
75class NpmEnvironment(object):
76 """
77 Using a npm config file seems more reliable than using cli arguments.
78 This class allows to create a controlled environment for npm commands.
79 """
80 def __init__(self, d, configs=None):
81 self.d = d
82 self.configs = configs
83
84 def run(self, cmd, args=None, configs=None, workdir=None):
85 """Run npm command in a controlled environment"""
86 with tempfile.TemporaryDirectory() as tmpdir:
87 d = bb.data.createCopy(self.d)
88 d.setVar("HOME", tmpdir)
89
90 cfgfile = os.path.join(tmpdir, "npmrc")
91
92 if not workdir:
93 workdir = tmpdir
94
95 def _run(cmd):
96 cmd = "NPM_CONFIG_USERCONFIG=%s " % cfgfile + cmd
97 cmd = "NPM_CONFIG_GLOBALCONFIG=%s " % cfgfile + cmd
98 return runfetchcmd(cmd, d, workdir=workdir)
99
100 if self.configs:
101 for key, value in self.configs:
102 _run("npm config set %s %s" % (key, shlex.quote(value)))
103
104 if configs:
105 for key, value in configs:
106 _run("npm config set %s %s" % (key, shlex.quote(value)))
107
108 if args:
109 for key, value in args:
110 cmd += " --%s=%s" % (key, shlex.quote(value))
111
112 return _run(cmd)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500113
114class Npm(FetchMethod):
Andrew Geissler82c905d2020-04-13 13:39:40 -0500115 """Class to fetch a package from a npm registry"""
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500116
117 def supports(self, ud, d):
Andrew Geissler82c905d2020-04-13 13:39:40 -0500118 """Check if a given url can be fetched with npm"""
119 return ud.type in ["npm"]
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500120
121 def urldata_init(self, ud, d):
Andrew Geissler82c905d2020-04-13 13:39:40 -0500122 """Init npm specific variables within url data"""
123 ud.package = None
124 ud.version = None
125 ud.registry = None
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500126
Andrew Geissler82c905d2020-04-13 13:39:40 -0500127 # Get the 'package' parameter
128 if "package" in ud.parm:
129 ud.package = ud.parm.get("package")
130
131 if not ud.package:
132 raise MissingParameterError("Parameter 'package' required", ud.url)
133
134 # Get the 'version' parameter
135 if "version" in ud.parm:
136 ud.version = ud.parm.get("version")
137
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500138 if not ud.version:
Andrew Geissler82c905d2020-04-13 13:39:40 -0500139 raise MissingParameterError("Parameter 'version' required", ud.url)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500140
Andrew Geissler82c905d2020-04-13 13:39:40 -0500141 if not is_semver(ud.version) and not ud.version == "latest":
142 raise ParameterError("Invalid 'version' parameter", ud.url)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500143
Andrew Geissler82c905d2020-04-13 13:39:40 -0500144 # Extract the 'registry' part of the url
145 ud.registry = re.sub(r"^npm://", "http://", ud.url.split(";")[0])
146
147 # Using the 'downloadfilename' parameter as local filename
148 # or the npm package name.
149 if "downloadfilename" in ud.parm:
150 ud.localfile = d.expand(ud.parm["downloadfilename"])
151 else:
152 ud.localfile = npm_localfile(ud.package, ud.version)
153
154 # Get the base 'npm' command
155 ud.basecmd = d.getVar("FETCHCMD_npm") or "npm"
156
157 # This fetcher resolves a URI from a npm package name and version and
158 # then forwards it to a proxy fetcher. A resolve file containing the
159 # resolved URI is created to avoid unwanted network access (if the file
160 # already exists). The management of the donestamp file, the lockfile
161 # and the checksums are forwarded to the proxy fetcher.
162 ud.proxy = None
163 ud.needdonestamp = False
164 ud.resolvefile = self.localpath(ud, d) + ".resolved"
165
166 def _resolve_proxy_url(self, ud, d):
167 def _npm_view():
168 configs = []
169 configs.append(("json", "true"))
170 configs.append(("registry", ud.registry))
171 pkgver = shlex.quote(ud.package + "@" + ud.version)
172 cmd = ud.basecmd + " view %s" % pkgver
173 env = NpmEnvironment(d)
174 check_network_access(d, cmd, ud.registry)
175 view_string = env.run(cmd, configs=configs)
176
177 if not view_string:
178 raise FetchError("Unavailable package %s" % pkgver, ud.url)
179
180 try:
181 view = json.loads(view_string)
182
183 error = view.get("error")
184 if error is not None:
185 raise FetchError(error.get("summary"), ud.url)
186
187 if ud.version == "latest":
188 bb.warn("The npm package %s is using the latest " \
189 "version available. This could lead to " \
190 "non-reproducible builds." % pkgver)
191 elif ud.version != view.get("version"):
192 raise ParameterError("Invalid 'version' parameter", ud.url)
193
194 return view
195
196 except Exception as e:
197 raise FetchError("Invalid view from npm: %s" % str(e), ud.url)
198
199 def _get_url(view):
200 tarball_url = view.get("dist", {}).get("tarball")
201
202 if tarball_url is None:
203 raise FetchError("Invalid 'dist.tarball' in view", ud.url)
204
205 uri = URI(tarball_url)
206 uri.params["downloadfilename"] = ud.localfile
207
208 integrity = view.get("dist", {}).get("integrity")
209 shasum = view.get("dist", {}).get("shasum")
210
211 if integrity is not None:
212 checksum_name, checksum_expected = npm_integrity(integrity)
213 uri.params[checksum_name] = checksum_expected
214 elif shasum is not None:
215 uri.params["sha1sum"] = shasum
216 else:
217 raise FetchError("Invalid 'dist.integrity' in view", ud.url)
218
219 return str(uri)
220
221 url = _get_url(_npm_view())
222
223 bb.utils.mkdirhier(os.path.dirname(ud.resolvefile))
224 with open(ud.resolvefile, "w") as f:
225 f.write(url)
226
227 def _setup_proxy(self, ud, d):
228 if ud.proxy is None:
229 if not os.path.exists(ud.resolvefile):
230 self._resolve_proxy_url(ud, d)
231
232 with open(ud.resolvefile, "r") as f:
233 url = f.read()
234
235 # Avoid conflicts between the environment data and:
236 # - the proxy url checksum
237 data = bb.data.createCopy(d)
238 data.delVarFlags("SRC_URI")
239 ud.proxy = Fetch([url], data)
240
241 def _get_proxy_method(self, ud, d):
242 self._setup_proxy(ud, d)
243 proxy_url = ud.proxy.urls[0]
244 proxy_ud = ud.proxy.ud[proxy_url]
245 proxy_d = ud.proxy.d
246 proxy_ud.setup_localpath(proxy_d)
247 return proxy_ud.method, proxy_ud, proxy_d
248
249 def verify_donestamp(self, ud, d):
250 """Verify the donestamp file"""
251 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
252 return proxy_m.verify_donestamp(proxy_ud, proxy_d)
253
254 def update_donestamp(self, ud, d):
255 """Update the donestamp file"""
256 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
257 proxy_m.update_donestamp(proxy_ud, proxy_d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500258
259 def need_update(self, ud, d):
Andrew Geissler82c905d2020-04-13 13:39:40 -0500260 """Force a fetch, even if localpath exists ?"""
261 if not os.path.exists(ud.resolvefile):
262 return True
263 if ud.version == "latest":
264 return True
265 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
266 return proxy_m.need_update(proxy_ud, proxy_d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500267
Andrew Geissler82c905d2020-04-13 13:39:40 -0500268 def try_mirrors(self, fetch, ud, d, mirrors):
269 """Try to use a mirror"""
270 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
271 return proxy_m.try_mirrors(fetch, proxy_ud, proxy_d, mirrors)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500272
273 def download(self, ud, d):
274 """Fetch url"""
Andrew Geissler82c905d2020-04-13 13:39:40 -0500275 self._setup_proxy(ud, d)
276 ud.proxy.download()
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500277
Andrew Geissler82c905d2020-04-13 13:39:40 -0500278 def unpack(self, ud, rootdir, d):
279 """Unpack the downloaded archive"""
280 destsuffix = ud.parm.get("destsuffix", "npm")
281 destdir = os.path.join(rootdir, destsuffix)
282 npm_unpack(ud.localpath, destdir, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500283
Andrew Geissler82c905d2020-04-13 13:39:40 -0500284 def clean(self, ud, d):
285 """Clean any existing full or partial download"""
286 if os.path.exists(ud.resolvefile):
287 self._setup_proxy(ud, d)
288 ud.proxy.clean()
289 bb.utils.remove(ud.resolvefile)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500290
Andrew Geissler82c905d2020-04-13 13:39:40 -0500291 def done(self, ud, d):
292 """Is the download done ?"""
293 if not os.path.exists(ud.resolvefile):
294 return False
295 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
296 return proxy_m.done(proxy_ud, proxy_d)