Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 1 | # Copyright (C) 2020 Savoir-Faire Linux |
Brad Bishop | c342db3 | 2019-05-15 21:57:59 -0400 | [diff] [blame] | 2 | # |
| 3 | # SPDX-License-Identifier: GPL-2.0-only |
| 4 | # |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 5 | """ |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 6 | BitBake 'Fetch' npm implementation |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 7 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 8 | npm fetcher support the SRC_URI with format of: |
| 9 | SRC_URI = "npm://some.registry.url;OptionA=xxx;OptionB=xxx;..." |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 10 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 11 | Supported SRC_URI options are: |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 12 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 13 | - package |
| 14 | The npm package name. This is a mandatory parameter. |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 15 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 16 | - version |
| 17 | The npm package version. This is a mandatory parameter. |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 18 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 19 | - downloadfilename |
| 20 | Specifies the filename used when storing the downloaded file. |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 21 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 22 | - destsuffix |
| 23 | Specifies the directory to use to unpack the package (default: npm). |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 24 | """ |
| 25 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 26 | import base64 |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 27 | import json |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 28 | import os |
| 29 | import re |
| 30 | import shlex |
| 31 | import tempfile |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 32 | import bb |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 33 | from bb.fetch2 import Fetch |
| 34 | from bb.fetch2 import FetchError |
| 35 | from bb.fetch2 import FetchMethod |
| 36 | from bb.fetch2 import MissingParameterError |
| 37 | from bb.fetch2 import ParameterError |
| 38 | from bb.fetch2 import URI |
| 39 | from bb.fetch2 import check_network_access |
| 40 | from bb.fetch2 import runfetchcmd |
| 41 | from bb.utils import is_semver |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 42 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 43 | def 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. |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 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 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 53 | |
| 54 | def npm_filename(package, version): |
| 55 | """Get the filename of a npm package""" |
| 56 | return npm_package(package) + "-" + version + ".tgz" |
| 57 | |
Andrew Geissler | 7e0e3c0 | 2022-02-25 20:34:39 +0000 | [diff] [blame] | 58 | def npm_localfile(package, version=None): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 59 | """Get the local filename of a npm package""" |
Andrew Geissler | 7e0e3c0 | 2022-02-25 20:34:39 +0000 | [diff] [blame] | 60 | if version is not None: |
| 61 | filename = npm_filename(package, version) |
| 62 | else: |
| 63 | filename = package |
| 64 | return os.path.join("npm2", filename) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 65 | |
| 66 | def 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 | |
| 74 | def 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" |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 79 | cmd += " --delay-directory-restore" |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 80 | cmd += " --strip-components=1" |
| 81 | runfetchcmd(cmd, d, workdir=destdir) |
Andrew Geissler | 595f630 | 2022-01-24 19:11:47 +0000 | [diff] [blame] | 82 | runfetchcmd("chmod -R +X '%s'" % (destdir), d, quiet=True, workdir=destdir) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 83 | |
| 84 | class 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 | """ |
Stefan Herbrechtsmeier | 3d983ec | 2021-12-13 12:13:53 +0100 | [diff] [blame] | 89 | def __init__(self, d, configs=[], npmrc=None): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 90 | self.d = d |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 91 | |
Stefan Herbrechtsmeier | 3d983ec | 2021-12-13 12:13:53 +0100 | [diff] [blame] | 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)) |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 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() |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Patrick Williams | e760df8 | 2023-05-26 11:10:49 -0500 | [diff] [blame] | 109 | d.setVar("PATH", d.getVar("PATH")) # PATH might contain $HOME - evaluate it before patching |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 110 | d.setVar("HOME", tmpdir) |
| 111 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 112 | if not workdir: |
| 113 | workdir = tmpdir |
| 114 | |
| 115 | def _run(cmd): |
Stefan Herbrechtsmeier | 3d983ec | 2021-12-13 12:13:53 +0100 | [diff] [blame] | 116 | cmd = "NPM_CONFIG_USERCONFIG=%s " % (self.user_config.name) + cmd |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 117 | cmd = "NPM_CONFIG_GLOBALCONFIG=%s " % (self.global_config_name) + cmd |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 118 | return runfetchcmd(cmd, d, workdir=workdir) |
| 119 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 120 | if configs: |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 121 | bb.warn("Use of configs argument of NpmEnvironment.run() function" |
| 122 | " is deprecated. Please use args argument instead.") |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 123 | for key, value in configs: |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 124 | cmd += " --%s=%s" % (key, shlex.quote(value)) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 125 | |
| 126 | if args: |
| 127 | for key, value in args: |
| 128 | cmd += " --%s=%s" % (key, shlex.quote(value)) |
| 129 | |
| 130 | return _run(cmd) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 131 | |
| 132 | class Npm(FetchMethod): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 133 | """Class to fetch a package from a npm registry""" |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 134 | |
| 135 | def supports(self, ud, d): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 136 | """Check if a given url can be fetched with npm""" |
| 137 | return ud.type in ["npm"] |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 138 | |
| 139 | def urldata_init(self, ud, d): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 140 | """Init npm specific variables within url data""" |
| 141 | ud.package = None |
| 142 | ud.version = None |
| 143 | ud.registry = None |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 144 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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 | |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 156 | if not ud.version: |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 157 | raise MissingParameterError("Parameter 'version' required", ud.url) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 158 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 159 | if not is_semver(ud.version) and not ud.version == "latest": |
| 160 | raise ParameterError("Invalid 'version' parameter", ud.url) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 161 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 162 | # Extract the 'registry' part of the url |
Patrick Williams | 92b42cb | 2022-09-03 06:53:57 -0500 | [diff] [blame] | 163 | ud.registry = re.sub(r"^npm://", "https://", ud.url.split(";")[0]) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 164 | |
| 165 | # Using the 'downloadfilename' parameter as local filename |
| 166 | # or the npm package name. |
| 167 | if "downloadfilename" in ud.parm: |
Andrew Geissler | 7e0e3c0 | 2022-02-25 20:34:39 +0000 | [diff] [blame] | 168 | ud.localfile = npm_localfile(d.expand(ud.parm["downloadfilename"])) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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(): |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 186 | args = [] |
| 187 | args.append(("json", "true")) |
| 188 | args.append(("registry", ud.registry)) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 193 | view_string = env.run(cmd, args=args) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 276 | |
| 277 | def need_update(self, ud, d): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 285 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 290 | |
| 291 | def download(self, ud, d): |
| 292 | """Fetch url""" |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 293 | self._setup_proxy(ud, d) |
| 294 | ud.proxy.download() |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 295 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Patrick Williams | ac13d5f | 2023-11-24 18:59:46 -0600 | [diff] [blame] | 301 | ud.unpack_tracer.unpack("npm", destdir) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 302 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 309 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 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) |