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