npm.py 10 KB

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