1# Copyright (C) 2020 Savoir-Faire Linux 2# 3# SPDX-License-Identifier: GPL-2.0-only 4# 5""" 6BitBake 'Fetch' npm implementation 7 8npm fetcher support the SRC_URI with format of: 9SRC_URI = "npm://some.registry.url;OptionA=xxx;OptionB=xxx;..." 10 11Supported SRC_URI options are: 12 13- package 14 The npm package name. This is a mandatory parameter. 15 16- version 17 The npm package version. This is a mandatory parameter. 18 19- downloadfilename 20 Specifies the filename used when storing the downloaded file. 21 22- destsuffix 23 Specifies the directory to use to unpack the package (default: npm). 24""" 25 26import base64 27import json 28import os 29import re 30import shlex 31import tempfile 32import bb 33from 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 42 43def 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 name = re.sub("/", "-", package) 48 name = name.lower() 49 name = re.sub(r"[^\-a-z0-9]", "", name) 50 name = name.strip("-") 51 return name 52 53 54def npm_filename(package, version): 55 """Get the filename of a npm package""" 56 return npm_package(package) + "-" + version + ".tgz" 57 58def npm_localfile(package, version=None): 59 """Get the local filename of a npm package""" 60 if version is not None: 61 filename = npm_filename(package, version) 62 else: 63 filename = package 64 return os.path.join("npm2", filename) 65 66def npm_integrity(integrity): 67 """ 68 Get the checksum name and expected value from the subresource integrity 69 https://www.w3.org/TR/SRI/ 70 """ 71 algo, value = integrity.split("-", maxsplit=1) 72 return "%ssum" % algo, base64.b64decode(value).hex() 73 74def npm_unpack(tarball, destdir, d): 75 """Unpack a npm tarball""" 76 bb.utils.mkdirhier(destdir) 77 cmd = "tar --extract --gzip --file=%s" % shlex.quote(tarball) 78 cmd += " --no-same-owner" 79 cmd += " --delay-directory-restore" 80 cmd += " --strip-components=1" 81 runfetchcmd(cmd, d, workdir=destdir) 82 runfetchcmd("chmod -R +X '%s'" % (destdir), d, quiet=True, workdir=destdir) 83 84class NpmEnvironment(object): 85 """ 86 Using a npm config file seems more reliable than using cli arguments. 87 This class allows to create a controlled environment for npm commands. 88 """ 89 def __init__(self, d, configs=[], npmrc=None): 90 self.d = d 91 92 self.user_config = tempfile.NamedTemporaryFile(mode="w", buffering=1) 93 for key, value in configs: 94 self.user_config.write("%s=%s\n" % (key, value)) 95 96 if npmrc: 97 self.global_config_name = npmrc 98 else: 99 self.global_config_name = "/dev/null" 100 101 def __del__(self): 102 if self.user_config: 103 self.user_config.close() 104 105 def run(self, cmd, args=None, configs=None, workdir=None): 106 """Run npm command in a controlled environment""" 107 with tempfile.TemporaryDirectory() as tmpdir: 108 d = bb.data.createCopy(self.d) 109 d.setVar("PATH", d.getVar("PATH")) # PATH might contain $HOME - evaluate it before patching 110 d.setVar("HOME", tmpdir) 111 112 if not workdir: 113 workdir = tmpdir 114 115 def _run(cmd): 116 cmd = "NPM_CONFIG_USERCONFIG=%s " % (self.user_config.name) + cmd 117 cmd = "NPM_CONFIG_GLOBALCONFIG=%s " % (self.global_config_name) + cmd 118 return runfetchcmd(cmd, d, workdir=workdir) 119 120 if configs: 121 bb.warn("Use of configs argument of NpmEnvironment.run() function" 122 " is deprecated. Please use args argument instead.") 123 for key, value in configs: 124 cmd += " --%s=%s" % (key, shlex.quote(value)) 125 126 if args: 127 for key, value in args: 128 cmd += " --%s=%s" % (key, shlex.quote(value)) 129 130 return _run(cmd) 131 132class Npm(FetchMethod): 133 """Class to fetch a package from a npm registry""" 134 135 def supports(self, ud, d): 136 """Check if a given url can be fetched with npm""" 137 return ud.type in ["npm"] 138 139 def urldata_init(self, ud, d): 140 """Init npm specific variables within url data""" 141 ud.package = None 142 ud.version = None 143 ud.registry = None 144 145 # Get the 'package' parameter 146 if "package" in ud.parm: 147 ud.package = ud.parm.get("package") 148 149 if not ud.package: 150 raise MissingParameterError("Parameter 'package' required", ud.url) 151 152 # Get the 'version' parameter 153 if "version" in ud.parm: 154 ud.version = ud.parm.get("version") 155 156 if not ud.version: 157 raise MissingParameterError("Parameter 'version' required", ud.url) 158 159 if not is_semver(ud.version) and not ud.version == "latest": 160 raise ParameterError("Invalid 'version' parameter", ud.url) 161 162 # Extract the 'registry' part of the url 163 ud.registry = re.sub(r"^npm://", "https://", ud.url.split(";")[0]) 164 165 # Using the 'downloadfilename' parameter as local filename 166 # or the npm package name. 167 if "downloadfilename" in ud.parm: 168 ud.localfile = npm_localfile(d.expand(ud.parm["downloadfilename"])) 169 else: 170 ud.localfile = npm_localfile(ud.package, ud.version) 171 172 # Get the base 'npm' command 173 ud.basecmd = d.getVar("FETCHCMD_npm") or "npm" 174 175 # This fetcher resolves a URI from a npm package name and version and 176 # then forwards it to a proxy fetcher. A resolve file containing the 177 # resolved URI is created to avoid unwanted network access (if the file 178 # already exists). The management of the donestamp file, the lockfile 179 # and the checksums are forwarded to the proxy fetcher. 180 ud.proxy = None 181 ud.needdonestamp = False 182 ud.resolvefile = self.localpath(ud, d) + ".resolved" 183 184 def _resolve_proxy_url(self, ud, d): 185 def _npm_view(): 186 args = [] 187 args.append(("json", "true")) 188 args.append(("registry", ud.registry)) 189 pkgver = shlex.quote(ud.package + "@" + ud.version) 190 cmd = ud.basecmd + " view %s" % pkgver 191 env = NpmEnvironment(d) 192 check_network_access(d, cmd, ud.registry) 193 view_string = env.run(cmd, args=args) 194 195 if not view_string: 196 raise FetchError("Unavailable package %s" % pkgver, ud.url) 197 198 try: 199 view = json.loads(view_string) 200 201 error = view.get("error") 202 if error is not None: 203 raise FetchError(error.get("summary"), ud.url) 204 205 if ud.version == "latest": 206 bb.warn("The npm package %s is using the latest " \ 207 "version available. This could lead to " \ 208 "non-reproducible builds." % pkgver) 209 elif ud.version != view.get("version"): 210 raise ParameterError("Invalid 'version' parameter", ud.url) 211 212 return view 213 214 except Exception as e: 215 raise FetchError("Invalid view from npm: %s" % str(e), ud.url) 216 217 def _get_url(view): 218 tarball_url = view.get("dist", {}).get("tarball") 219 220 if tarball_url is None: 221 raise FetchError("Invalid 'dist.tarball' in view", ud.url) 222 223 uri = URI(tarball_url) 224 uri.params["downloadfilename"] = ud.localfile 225 226 integrity = view.get("dist", {}).get("integrity") 227 shasum = view.get("dist", {}).get("shasum") 228 229 if integrity is not None: 230 checksum_name, checksum_expected = npm_integrity(integrity) 231 uri.params[checksum_name] = checksum_expected 232 elif shasum is not None: 233 uri.params["sha1sum"] = shasum 234 else: 235 raise FetchError("Invalid 'dist.integrity' in view", ud.url) 236 237 return str(uri) 238 239 url = _get_url(_npm_view()) 240 241 bb.utils.mkdirhier(os.path.dirname(ud.resolvefile)) 242 with open(ud.resolvefile, "w") as f: 243 f.write(url) 244 245 def _setup_proxy(self, ud, d): 246 if ud.proxy is None: 247 if not os.path.exists(ud.resolvefile): 248 self._resolve_proxy_url(ud, d) 249 250 with open(ud.resolvefile, "r") as f: 251 url = f.read() 252 253 # Avoid conflicts between the environment data and: 254 # - the proxy url checksum 255 data = bb.data.createCopy(d) 256 data.delVarFlags("SRC_URI") 257 ud.proxy = Fetch([url], data) 258 259 def _get_proxy_method(self, ud, d): 260 self._setup_proxy(ud, d) 261 proxy_url = ud.proxy.urls[0] 262 proxy_ud = ud.proxy.ud[proxy_url] 263 proxy_d = ud.proxy.d 264 proxy_ud.setup_localpath(proxy_d) 265 return proxy_ud.method, proxy_ud, proxy_d 266 267 def verify_donestamp(self, ud, d): 268 """Verify the donestamp file""" 269 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) 270 return proxy_m.verify_donestamp(proxy_ud, proxy_d) 271 272 def update_donestamp(self, ud, d): 273 """Update the donestamp file""" 274 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) 275 proxy_m.update_donestamp(proxy_ud, proxy_d) 276 277 def need_update(self, ud, d): 278 """Force a fetch, even if localpath exists ?""" 279 if not os.path.exists(ud.resolvefile): 280 return True 281 if ud.version == "latest": 282 return True 283 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) 284 return proxy_m.need_update(proxy_ud, proxy_d) 285 286 def try_mirrors(self, fetch, ud, d, mirrors): 287 """Try to use a mirror""" 288 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) 289 return proxy_m.try_mirrors(fetch, proxy_ud, proxy_d, mirrors) 290 291 def download(self, ud, d): 292 """Fetch url""" 293 self._setup_proxy(ud, d) 294 ud.proxy.download() 295 296 def unpack(self, ud, rootdir, d): 297 """Unpack the downloaded archive""" 298 destsuffix = ud.parm.get("destsuffix", "npm") 299 destdir = os.path.join(rootdir, destsuffix) 300 npm_unpack(ud.localpath, destdir, d) 301 ud.unpack_tracer.unpack("npm", destdir) 302 303 def clean(self, ud, d): 304 """Clean any existing full or partial download""" 305 if os.path.exists(ud.resolvefile): 306 self._setup_proxy(ud, d) 307 ud.proxy.clean() 308 bb.utils.remove(ud.resolvefile) 309 310 def done(self, ud, d): 311 """Is the download done ?""" 312 if not os.path.exists(ud.resolvefile): 313 return False 314 proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) 315 return proxy_m.done(proxy_ud, proxy_d) 316