| # Copyright (C) 2020 Savoir-Faire Linux |
| # |
| # SPDX-License-Identifier: GPL-2.0-only |
| # |
| """ |
| BitBake 'Fetch' npm implementation |
| |
| npm fetcher support the SRC_URI with format of: |
| SRC_URI = "npm://some.registry.url;OptionA=xxx;OptionB=xxx;..." |
| |
| Supported SRC_URI options are: |
| |
| - package |
| The npm package name. This is a mandatory parameter. |
| |
| - version |
| The npm package version. This is a mandatory parameter. |
| |
| - sha512sum / sha256sum / sha384sum / sha1sum |
| The expected checksum of the downloaded tarball. Exactly one is |
| mandatory: the registry cannot be trusted to supply its own tamper |
| detection, so the checksum must come from the recipe. |
| |
| - downloadfilename |
| Specifies the filename used when storing the downloaded file. |
| |
| - destsuffix |
| Specifies the directory to use to unpack the package (default: npm). |
| """ |
| |
| import base64 |
| import json |
| import os |
| import re |
| import shlex |
| import tempfile |
| import bb |
| from bb.fetch import Fetch |
| from bb.fetch import FetchError |
| from bb.fetch import FetchMethod |
| from bb.fetch import MalformedUrl |
| from bb.fetch import MissingParameterError |
| from bb.fetch import ParameterError |
| from bb.fetch import URI |
| from bb.fetch import check_network_access |
| from bb.fetch import runfetchcmd |
| from bb.utils import is_semver |
| |
| # Preference order matches strength; the first one present in SRC_URI wins. |
| CHECKSUM_PARMS = ("sha512sum", "sha256sum", "sha384sum", "sha1sum") |
| |
| def npm_package(package): |
| """Convert the npm package name to remove unsupported character""" |
| # For scoped package names ('@user/package') the '/' is replaced by a '-'. |
| # This is similar to what 'npm pack' does, but 'npm pack' also strips the |
| # leading '@', which can lead to ambiguous package names. |
| name = re.sub("/", "-", package) |
| name = name.lower() |
| name = re.sub(r"[^\-a-z0-9@]", "", name) |
| name = name.strip("-") |
| return name |
| |
| |
| def npm_filename(package, version): |
| """Get the filename of a npm package""" |
| return npm_package(package) + "-" + version + ".tgz" |
| |
| def npm_localfile(package, version=None): |
| """Get the local filename of a npm package""" |
| if version is not None: |
| filename = npm_filename(package, version) |
| else: |
| filename = package |
| return os.path.join("npm2", filename) |
| |
| def npm_integrity(integrity): |
| """ |
| Get the checksum name and expected value from the subresource integrity |
| https://www.w3.org/TR/SRI/ |
| """ |
| algo, value = integrity.split("-", maxsplit=1) |
| return "%ssum" % algo, base64.b64decode(value).hex() |
| |
| def npm_unpack(tarball, destdir, d): |
| """Unpack a npm tarball""" |
| bb.utils.mkdirhier(destdir) |
| cmd = ['tar', '--extract', '--gzip', '--file=%s' % tarball, '--no-same-owner', '--delay-directory-restore', '--strip-components=1'] |
| runfetchcmd(cmd, d, workdir=destdir) |
| runfetchcmd(['chmod', '-R', '+X', destdir], d, quiet=True, workdir=destdir) |
| |
| class NpmEnvironment(object): |
| """ |
| Using a npm config file seems more reliable than using cli arguments. |
| This class allows to create a controlled environment for npm commands. |
| """ |
| def __init__(self, d, configs=[], npmrc=None): |
| self.d = d |
| |
| self.user_config = tempfile.NamedTemporaryFile(mode="w", buffering=1) |
| |
| hn = self._home_npmrc(d) |
| if hn is not None: |
| with open(hn, 'r') as hnf: |
| self.user_config.write(hnf.read()) |
| |
| for key, value in configs: |
| self.user_config.write("%s=%s\n" % (key, value)) |
| |
| if npmrc: |
| self.global_config_name = npmrc |
| else: |
| self.global_config_name = "/dev/null" |
| |
| def __del__(self): |
| if self.user_config: |
| self.user_config.close() |
| |
| def _home_npmrc(self, d): |
| """Function to return user's HOME .npmrc file (or None if it doesn't exist)""" |
| home_npmrc_file = os.path.join(os.environ.get("HOME"), ".npmrc") |
| if d.getVar("BB_USE_HOME_NPMRC") == "1" and os.path.exists(home_npmrc_file): |
| bb.warn(f"BB_USE_HOME_NPMRC flag set and valid .npmrc detected - "\ |
| f"npm fetcher will use {home_npmrc_file}") |
| return home_npmrc_file |
| return None |
| |
| def run(self, cmd, args=None, configs=None, workdir=None): |
| """Run npm command in a controlled environment""" |
| with tempfile.TemporaryDirectory() as tmpdir: |
| d = bb.data.createCopy(self.d) |
| d.setVar("PATH", d.getVar("PATH")) # PATH might contain $HOME - evaluate it before patching |
| d.setVar("HOME", tmpdir) |
| |
| if not workdir: |
| workdir = tmpdir |
| |
| def _run(cmd): |
| extraenv = {} |
| extraenv['NPM_CONFIG_USERCONFIG'] = self.user_config.name |
| extraenv['NPM_CONFIG_GLOBALCONFIG'] = self.global_config_name |
| return runfetchcmd(cmd, d, workdir=workdir, extraenv=extraenv) |
| |
| if configs: |
| bb.warn("Use of configs argument of NpmEnvironment.run() function" |
| " is deprecated. Please use args argument instead.") |
| for key, value in configs: |
| cmd.append('--%s=%s' % (key, value)) |
| |
| if args: |
| for key, value in args: |
| cmd.append('--%s=%s' % (key, value)) |
| |
| return _run(cmd) |
| |
| class Npm(FetchMethod): |
| """Class to fetch a package from a npm registry""" |
| |
| def supports(self, ud, d): |
| """Check if a given url can be fetched with npm""" |
| return ud.type in ["npm"] |
| |
| def urldata_init(self, ud, d): |
| """Init npm specific variables within url data""" |
| ud.package = None |
| ud.version = None |
| ud.registry = None |
| |
| # Get the 'package' parameter |
| if "package" in ud.parm: |
| ud.package = ud.parm.get("package") |
| |
| if not ud.package: |
| raise MissingParameterError("Parameter 'package' required", ud.url) |
| |
| # Get the 'version' parameter |
| if "version" in ud.parm: |
| ud.version = ud.parm.get("version") |
| |
| if not ud.version: |
| raise MissingParameterError("Parameter 'version' required", ud.url) |
| |
| if ud.version == "latest": |
| raise ParameterError( |
| "Version 'latest' is not reproducible; specify an exact semver version", |
| ud.url) |
| |
| if not is_semver(ud.version): |
| raise ParameterError("Invalid 'version' parameter", ud.url) |
| |
| # Extract the 'registry' part of the url |
| ud.registry = re.sub(r"^npm://", "https://", ud.url.split(";")[0]) |
| if not ud.url.split(";")[0][len("npm://"):]: |
| raise MalformedUrl(ud.url) |
| |
| # A checksum is mandatory: the registry cannot be trusted to supply |
| # its own tamper detection, so the recipe must commit to one. |
| for cp in CHECKSUM_PARMS: |
| if cp in ud.parm: |
| ud.checksum_name = cp |
| ud.checksum_expected = ud.parm[cp] |
| break |
| else: |
| raise MissingParameterError( |
| "Missing checksum for npm package '%s@%s': a compromised " |
| "registry could otherwise serve a tampered tarball undetected. " |
| "Add one of %s to SRC_URI." % (ud.package, ud.version, ", ".join(CHECKSUM_PARMS)), |
| ud.url) |
| |
| # Using the 'downloadfilename' parameter as local filename |
| # or the npm package name. |
| if "downloadfilename" in ud.parm: |
| ud.localfile = npm_localfile(ud.parm["downloadfilename"]) |
| else: |
| ud.localfile = npm_localfile(ud.package, ud.version) |
| |
| # Get the base 'npm' command |
| ud.basecmd = shlex.split(d.getVar("FETCHCMD_npm") or "") or ["npm"] |
| |
| # This fetcher resolves a URI from a npm package name and version and |
| # then forwards it to a proxy fetcher. A resolve file containing the |
| # resolved URI is created to avoid unwanted network access (if the file |
| # already exists). The management of the donestamp file, the lockfile |
| # and the checksums are forwarded to the proxy fetcher. |
| ud.proxy = None |
| ud.needdonestamp = False |
| ud.resolvefile = self.localpath(ud, d) + ".resolved" |
| |
| def _resolve_proxy_url(self, ud, d): |
| """Resolve the tarball URL from the registry and cache it without any checksum. |
| |
| Checksums must never be sourced from the registry: a compromised registry |
| controls both the tarball and its advertised hash, so any checksum obtained |
| there provides no tamper detection. Checksums are applied in _setup_proxy |
| from the recipe-provided SRC_URI parameters instead. |
| """ |
| def _npm_view(): |
| args = [] |
| args.append(("json", "true")) |
| args.append(("registry", ud.registry)) |
| pkgver = ud.package + "@" + ud.version |
| cmd = ud.basecmd + ['view', pkgver] |
| env = NpmEnvironment(d) |
| check_network_access(d, cmd, ud.registry) |
| view_string = env.run(cmd, args=args) |
| |
| if not view_string: |
| raise FetchError("Unavailable package %s" % pkgver, ud.url) |
| |
| try: |
| view = json.loads(view_string) |
| except json.JSONDecodeError as e: |
| raise FetchError("Invalid view from npm: %s" % str(e), ud.url) |
| |
| error = view.get("error") |
| if error is not None: |
| raise FetchError(error.get("summary") or str(error), ud.url) |
| |
| if ud.version != view.get("version"): |
| raise ParameterError("Invalid 'version' parameter", ud.url) |
| |
| return view |
| |
| view = _npm_view() |
| tarball_url = view.get("dist", {}).get("tarball") |
| |
| if tarball_url is None: |
| raise FetchError("Invalid 'dist.tarball' in view", ud.url) |
| |
| bb.utils.mkdirhier(os.path.dirname(ud.resolvefile)) |
| with open(ud.resolvefile, "w") as f: |
| f.write(tarball_url) |
| |
| def _setup_proxy(self, ud, d): |
| if ud.proxy is None: |
| if not os.path.exists(ud.resolvefile): |
| self._resolve_proxy_url(ud, d) |
| |
| with open(ud.resolvefile, "r") as f: |
| tarball_url = f.read().strip() |
| |
| uri = URI(tarball_url) |
| # Discard any params that may have been embedded in the stored URL |
| # (e.g. from an npmsw-written .resolved file) and rebuild from |
| # scratch so that registry-sourced checksums can never flow through. |
| uri.params = {} |
| uri.params["downloadfilename"] = ud.localfile |
| |
| # Inject the recipe-provided checksum into the proxy URL. |
| # Checksums from the remote registry are never used; only the |
| # value validated in urldata_init, sourced from the recipe, is |
| # trusted. |
| uri.params[ud.checksum_name] = ud.checksum_expected |
| |
| # Avoid conflicts between the environment data and: |
| # - the proxy url checksum |
| data = bb.data.createCopy(d) |
| data.delVarFlags("SRC_URI") |
| ud.proxy = Fetch([str(uri)], data) |
| |
| def _get_proxy_method(self, ud, d): |
| self._setup_proxy(ud, d) |
| proxy_url = ud.proxy.urls[0] |
| proxy_ud = ud.proxy.ud[proxy_url] |
| proxy_d = ud.proxy.d |
| proxy_ud.setup_localpath(proxy_d) |
| return proxy_ud.method, proxy_ud, proxy_d |
| |
| def verify_donestamp(self, ud, d): |
| """Verify the donestamp file""" |
| proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) |
| return proxy_m.verify_donestamp(proxy_ud, proxy_d) |
| |
| def update_donestamp(self, ud, d): |
| """Update the donestamp file""" |
| proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) |
| proxy_m.update_donestamp(proxy_ud, proxy_d) |
| |
| def need_update(self, ud, d): |
| """Force a fetch, even if localpath exists ?""" |
| if not os.path.exists(ud.resolvefile): |
| return True |
| proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) |
| return proxy_m.need_update(proxy_ud, proxy_d) |
| |
| def try_mirrors(self, fetch, ud, d, mirrors): |
| """Try to use a mirror""" |
| proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) |
| return proxy_m.try_mirrors(fetch, proxy_ud, proxy_d, mirrors) |
| |
| def download(self, ud, d): |
| """Fetch url""" |
| self._setup_proxy(ud, d) |
| ud.proxy.download() |
| |
| def unpack(self, ud, rootdir, d): |
| """Unpack the downloaded archive""" |
| destsuffix = ud.parm.get("destsuffix", "npm") |
| destdir = os.path.join(rootdir, destsuffix) |
| npm_unpack(ud.localpath, destdir, d) |
| ud.unpack_tracer.unpack("npm", destdir) |
| |
| def clean(self, ud, d): |
| """Clean any existing full or partial download""" |
| if os.path.exists(ud.resolvefile): |
| self._setup_proxy(ud, d) |
| ud.proxy.clean() |
| bb.utils.remove(ud.resolvefile) |
| |
| def done(self, ud, d): |
| """Is the download done ?""" |
| if not os.path.exists(ud.resolvefile): |
| return False |
| proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d) |
| return proxy_m.done(proxy_ud, proxy_d) |