xref: /OK3568_Linux_fs/yocto/poky/bitbake/lib/bb/fetch2/npm.py (revision 4882a59341e53eb6f0b4789bf948001014eff981)
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